Spaces:
Running
Running
First Push
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- .claude/settings.local.json +11 -0
- APPLICATIONS_GUIDE.md +200 -0
- CLAUDE_DESKTOP_SETUP.md +99 -0
- DEPLOYMENT.md +107 -0
- Dockerfile +69 -0
- Empc-hackathonbackendmcp_server.py +0 -0
- Empc-hackathonpublicbackground_readme.txt +1 -0
- HACKATHON_README.md +215 -0
- MCP_SETUP_GUIDE.md +201 -0
- TEST_MCP_CONNECTION.md +76 -0
- TTT_CLAUDE_DESKTOP_GUIDE.md +257 -0
- USE_NGROK.md +27 -0
- app/api/code/execute/route.ts +186 -0
- app/api/code/public/route.ts +85 -0
- app/api/code/save/route.ts +130 -0
- app/api/download/route.ts +99 -0
- app/api/files/route.ts +183 -0
- app/api/gemini/chat/route.ts +113 -0
- app/api/gemini/transcribe/route.ts +94 -0
- app/api/public/route.ts +178 -0
- app/api/upload/route.ts +84 -0
- app/components/AboutModal.tsx +179 -0
- app/components/BackgroundSelector.tsx +217 -0
- app/components/Calendar.tsx +301 -0
- app/components/ClaudeIntegration.tsx +240 -0
- app/components/Clock.tsx +188 -0
- app/components/CodeExecutor.tsx +306 -0
- app/components/CodePlayground.tsx +668 -0
- app/components/ContextMenu.tsx +90 -0
- app/components/Desktop.tsx +498 -0
- app/components/DesktopContextMenu.tsx +123 -0
- app/components/DesktopIcon.tsx +64 -0
- app/components/Dock.tsx +170 -0
- app/components/DraggableDesktopIcon.tsx +146 -0
- app/components/FileManager.tsx +542 -0
- app/components/FilePreview.tsx +372 -0
- app/components/GeminiChat.tsx +258 -0
- app/components/HelpModal.tsx +172 -0
- app/components/MatrixRain.tsx +236 -0
- app/components/PDFViewer.tsx +143 -0
- app/components/SpotlightSearch.tsx +182 -0
- app/components/Terminal.tsx +223 -0
- app/components/TopBar.tsx +122 -0
- app/components/VSCodeEditor.tsx +458 -0
- app/components/WebBrowser.tsx +277 -0
- app/components/WebBrowserApp.tsx +438 -0
- app/components/Window.tsx +132 -0
- app/gemini/page.tsx +426 -0
- app/globals.css +301 -12
- app/hooks/useKV.ts +37 -0
.claude/settings.local.json
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"permissions": {
|
| 3 |
+
"allow": [
|
| 4 |
+
"Bash(dir:*)",
|
| 5 |
+
"Bash(del nul)",
|
| 6 |
+
"Bash(rm:*)"
|
| 7 |
+
],
|
| 8 |
+
"deny": [],
|
| 9 |
+
"ask": []
|
| 10 |
+
}
|
| 11 |
+
}
|
APPLICATIONS_GUIDE.md
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Applications Guide
|
| 2 |
+
|
| 3 |
+
## Integrated Desktop Applications
|
| 4 |
+
|
| 5 |
+
Your web-based desktop OS now includes the following applications:
|
| 6 |
+
|
| 7 |
+
### 1. **Web Browser** 🌐
|
| 8 |
+
- **Icon**: Globe icon (blue/cyan gradient)
|
| 9 |
+
- **Location**: Desktop + Dock
|
| 10 |
+
- **Features**:
|
| 11 |
+
- Multi-tab browsing
|
| 12 |
+
- Navigation controls (back, forward, refresh, home)
|
| 13 |
+
- URL bar with HTTPS indicator
|
| 14 |
+
- Quick links to iframe-friendly sites
|
| 15 |
+
- CORS proxy option for better website compatibility
|
| 16 |
+
- Fullscreen mode
|
| 17 |
+
- Open in new window option
|
| 18 |
+
- Resizable and draggable window
|
| 19 |
+
|
| 20 |
+
**CORS Proxy Solution**:
|
| 21 |
+
- Toggle in settings to enable/disable
|
| 22 |
+
- Uses `allorigins.win` proxy by default
|
| 23 |
+
- Bypasses cross-origin restrictions
|
| 24 |
+
- May affect performance slightly
|
| 25 |
+
|
| 26 |
+
**Quick Access Sites**:
|
| 27 |
+
- Google
|
| 28 |
+
- Wikipedia
|
| 29 |
+
- MDN Web Docs
|
| 30 |
+
- Stack Overflow
|
| 31 |
+
- GitHub
|
| 32 |
+
- DuckDuckGo
|
| 33 |
+
|
| 34 |
+
### 2. **Gemini Chat** ✨
|
| 35 |
+
- **Icon**: Sparkle icon (orange gradient #E95420)
|
| 36 |
+
- **Location**: Desktop + Dock
|
| 37 |
+
- **Features**:
|
| 38 |
+
- AI-powered chat interface
|
| 39 |
+
- Conversation history (saved to localStorage)
|
| 40 |
+
- Message timestamps
|
| 41 |
+
- Draggable and resizable window
|
| 42 |
+
- Clean, modern UI matching Ubuntu style
|
| 43 |
+
- API key management (stored locally)
|
| 44 |
+
|
| 45 |
+
**Setup**:
|
| 46 |
+
1. Click the Gemini Chat icon on desktop or dock
|
| 47 |
+
2. Enter your Gemini API key (get from [Google AI Studio](https://makersuite.google.com/app/apikey))
|
| 48 |
+
3. Start chatting!
|
| 49 |
+
|
| 50 |
+
**Features**:
|
| 51 |
+
- Context-aware responses (last 10 messages)
|
| 52 |
+
- Real-time typing indicators
|
| 53 |
+
- Clear chat history option
|
| 54 |
+
- Persistent chat across sessions
|
| 55 |
+
- Secure API key storage
|
| 56 |
+
|
| 57 |
+
### 3. **File Manager** 📁
|
| 58 |
+
- Document management
|
| 59 |
+
- File browsing
|
| 60 |
+
- Multiple file type support
|
| 61 |
+
|
| 62 |
+
### 4. **Calendar** 📅
|
| 63 |
+
- Exam scheduling
|
| 64 |
+
- Date management
|
| 65 |
+
- Event tracking
|
| 66 |
+
|
| 67 |
+
### 5. **Help** ❓
|
| 68 |
+
- System help and information
|
| 69 |
+
- Usage guides
|
| 70 |
+
|
| 71 |
+
## API Integration
|
| 72 |
+
|
| 73 |
+
### Gemini API Routes
|
| 74 |
+
|
| 75 |
+
**Chat Endpoint**: `/api/gemini/chat`
|
| 76 |
+
- Method: POST
|
| 77 |
+
- Body: `{ message, apiKey, history }`
|
| 78 |
+
- Response: `{ response }` or `{ error }`
|
| 79 |
+
- Uses: Gemini 1.5 Flash model
|
| 80 |
+
|
| 81 |
+
**Transcription Endpoint**: `/api/gemini/transcribe`
|
| 82 |
+
- Method: POST
|
| 83 |
+
- Body: FormData with audio file and apiKey
|
| 84 |
+
- Response: `{ transcription }` or `{ error }`
|
| 85 |
+
- Note: Full audio support requires Gemini 1.5 Pro
|
| 86 |
+
|
| 87 |
+
## Accessing Applications
|
| 88 |
+
|
| 89 |
+
### Desktop Icons
|
| 90 |
+
Click any icon on the desktop (center of screen):
|
| 91 |
+
- Documents
|
| 92 |
+
- Exam Calendar
|
| 93 |
+
- Web Browser
|
| 94 |
+
- Gemini Chat
|
| 95 |
+
|
| 96 |
+
### Dock (Left sidebar)
|
| 97 |
+
Quick access to all applications:
|
| 98 |
+
- Applications menu
|
| 99 |
+
- Documents
|
| 100 |
+
- Exam Calendar
|
| 101 |
+
- Web Browser
|
| 102 |
+
- Gemini Chat
|
| 103 |
+
- Help
|
| 104 |
+
|
| 105 |
+
## Window Management
|
| 106 |
+
|
| 107 |
+
All applications support:
|
| 108 |
+
- **Dragging**: Click and drag the title bar
|
| 109 |
+
- **Resizing**: Drag from edges or corners
|
| 110 |
+
- **Closing**: Click the red close button (top-left)
|
| 111 |
+
- **Minimize/Maximize**: Click window control buttons
|
| 112 |
+
|
| 113 |
+
## Browser Usage Tips
|
| 114 |
+
|
| 115 |
+
1. **For Best Compatibility**: Enable CORS proxy in browser settings
|
| 116 |
+
2. **Interactive Sites**: Some features may not work through proxy
|
| 117 |
+
3. **Performance**: Direct access (no proxy) is faster but limited
|
| 118 |
+
4. **Quick Links**: Use provided shortcuts for reliable access
|
| 119 |
+
|
| 120 |
+
## Gemini Chat Tips
|
| 121 |
+
|
| 122 |
+
1. **API Key**: Keep it secure, it's stored locally in your browser
|
| 123 |
+
2. **Context**: The AI remembers last 10 messages in conversation
|
| 124 |
+
3. **Clear History**: Use "Clear" button to start fresh conversation
|
| 125 |
+
4. **Error Handling**: Check API key if you encounter errors
|
| 126 |
+
5. **Conversation**: Chat naturally, Gemini understands context
|
| 127 |
+
|
| 128 |
+
## Technical Details
|
| 129 |
+
|
| 130 |
+
### Technologies Used
|
| 131 |
+
- **Frontend**: React 19, Next.js 16, TypeScript
|
| 132 |
+
- **Styling**: Tailwind CSS 4
|
| 133 |
+
- **Icons**: Phosphor Icons
|
| 134 |
+
- **Animations**: Framer Motion
|
| 135 |
+
- **State**: React hooks + localStorage
|
| 136 |
+
|
| 137 |
+
### File Structure
|
| 138 |
+
```
|
| 139 |
+
app/
|
| 140 |
+
├── components/
|
| 141 |
+
│ ├── Desktop.tsx # Main desktop container
|
| 142 |
+
│ ├── Dock.tsx # Application dock
|
| 143 |
+
│ ├── DesktopIcon.tsx # Desktop icon component
|
| 144 |
+
│ ├── WebBrowserApp.tsx # Browser application
|
| 145 |
+
│ ├── GeminiChat.tsx # Gemini chat application
|
| 146 |
+
│ └── ...
|
| 147 |
+
├── api/
|
| 148 |
+
│ └── gemini/
|
| 149 |
+
│ ├── chat/route.ts # Chat API endpoint
|
| 150 |
+
│ └── transcribe/route.ts # Transcription endpoint
|
| 151 |
+
└── page.tsx # Main entry point
|
| 152 |
+
```
|
| 153 |
+
|
| 154 |
+
## Future Enhancements
|
| 155 |
+
|
| 156 |
+
Potential improvements:
|
| 157 |
+
- [ ] Browser bookmarks system
|
| 158 |
+
- [ ] Download manager
|
| 159 |
+
- [ ] Browser history
|
| 160 |
+
- [ ] Gemini voice input
|
| 161 |
+
- [ ] Gemini image analysis
|
| 162 |
+
- [ ] Multiple chat sessions
|
| 163 |
+
- [ ] Chat export/import
|
| 164 |
+
- [ ] Custom themes
|
| 165 |
+
- [ ] Keyboard shortcuts
|
| 166 |
+
- [ ] Window snapping
|
| 167 |
+
|
| 168 |
+
## Troubleshooting
|
| 169 |
+
|
| 170 |
+
### Browser Not Loading Sites
|
| 171 |
+
- Enable CORS proxy in settings
|
| 172 |
+
- Try quick link sites first
|
| 173 |
+
- Some sites block iframe embedding
|
| 174 |
+
|
| 175 |
+
### Gemini Chat Not Working
|
| 176 |
+
- Verify API key is correct
|
| 177 |
+
- Check internet connection
|
| 178 |
+
- Ensure API key has proper permissions
|
| 179 |
+
- Try clearing chat and starting fresh
|
| 180 |
+
|
| 181 |
+
### Window Issues
|
| 182 |
+
- Refresh page if window becomes unresponsive
|
| 183 |
+
- Check that window isn't dragged off-screen
|
| 184 |
+
- Clear localStorage if persistent issues
|
| 185 |
+
|
| 186 |
+
## Development
|
| 187 |
+
|
| 188 |
+
To run the application:
|
| 189 |
+
```bash
|
| 190 |
+
npm run dev
|
| 191 |
+
```
|
| 192 |
+
|
| 193 |
+
Access at: `http://localhost:3000`
|
| 194 |
+
|
| 195 |
+
## API Keys
|
| 196 |
+
|
| 197 |
+
You'll need:
|
| 198 |
+
- **Gemini API Key**: From [Google AI Studio](https://makersuite.google.com/app/apikey)
|
| 199 |
+
|
| 200 |
+
Keys are stored locally in browser's localStorage for convenience.
|
CLAUDE_DESKTOP_SETUP.md
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Setting Up MCP with Claude Desktop
|
| 2 |
+
|
| 3 |
+
## Step 1: Find Your Claude Desktop Config File
|
| 4 |
+
|
| 5 |
+
Open File Explorer and navigate to:
|
| 6 |
+
```
|
| 7 |
+
C:\Users\[YourUsername]\AppData\Roaming\Claude\
|
| 8 |
+
```
|
| 9 |
+
|
| 10 |
+
Or press `Win+R` and type:
|
| 11 |
+
```
|
| 12 |
+
%APPDATA%\Claude
|
| 13 |
+
```
|
| 14 |
+
|
| 15 |
+
## Step 2: Edit claude_desktop_config.json
|
| 16 |
+
|
| 17 |
+
If the file doesn't exist, create it. Add this content:
|
| 18 |
+
|
| 19 |
+
```json
|
| 20 |
+
{
|
| 21 |
+
"mcpServers": {
|
| 22 |
+
"semsoon": {
|
| 23 |
+
"command": "python",
|
| 24 |
+
"args": ["E:\\mpc-hackathon\\backend\\mcp_server.py"],
|
| 25 |
+
"env": {
|
| 26 |
+
"UPLOAD_PASSCODE": "semsoon-secure-2025",
|
| 27 |
+
"DATA_DIR": "E:\\mpc-hackathon\\data"
|
| 28 |
+
}
|
| 29 |
+
}
|
| 30 |
+
}
|
| 31 |
+
}
|
| 32 |
+
```
|
| 33 |
+
|
| 34 |
+
## Step 3: Restart Claude Desktop
|
| 35 |
+
|
| 36 |
+
1. Completely close Claude Desktop (check system tray)
|
| 37 |
+
2. Open Claude Desktop again
|
| 38 |
+
3. The MCP server should start automatically
|
| 39 |
+
|
| 40 |
+
## Step 4: Test in Claude Desktop
|
| 41 |
+
|
| 42 |
+
Open a new conversation and try these:
|
| 43 |
+
|
| 44 |
+
1. **Basic test**: "What MCP servers are available?"
|
| 45 |
+
- Claude should mention "semsoon"
|
| 46 |
+
|
| 47 |
+
2. **List documents**: "Using semsoon, show me all available documents"
|
| 48 |
+
- Should list the sample documents
|
| 49 |
+
|
| 50 |
+
3. **Check exams**: "Using semsoon, what exams are scheduled?"
|
| 51 |
+
- Should show the sample exams
|
| 52 |
+
|
| 53 |
+
4. **Read a file**: "Using semsoon, read the study_tips.md file"
|
| 54 |
+
- Should display the content
|
| 55 |
+
|
| 56 |
+
## If It's Not Working
|
| 57 |
+
|
| 58 |
+
### Check 1: Python Path
|
| 59 |
+
Make sure Python is in your PATH:
|
| 60 |
+
```cmd
|
| 61 |
+
python --version
|
| 62 |
+
```
|
| 63 |
+
|
| 64 |
+
If not, use full path in config:
|
| 65 |
+
```json
|
| 66 |
+
"command": "C:\\Python311\\python.exe"
|
| 67 |
+
```
|
| 68 |
+
|
| 69 |
+
### Check 2: Dependencies
|
| 70 |
+
```cmd
|
| 71 |
+
pip install fastmcp PyPDF2 python-docx python-dotenv
|
| 72 |
+
```
|
| 73 |
+
|
| 74 |
+
### Check 3: Test Server Manually
|
| 75 |
+
```cmd
|
| 76 |
+
cd E:\mpc-hackathon\backend
|
| 77 |
+
python mcp_server.py
|
| 78 |
+
```
|
| 79 |
+
Should show: "Uvicorn running on http://127.0.0.1:8000"
|
| 80 |
+
|
| 81 |
+
### Check 4: Claude Desktop Logs
|
| 82 |
+
In Claude Desktop, press `Ctrl+Shift+I` to open DevTools and check Console for errors.
|
| 83 |
+
|
| 84 |
+
## What Success Looks Like
|
| 85 |
+
|
| 86 |
+
When properly connected, Claude Desktop will:
|
| 87 |
+
1. Show "semsoon" as an available MCP server
|
| 88 |
+
2. Be able to list documents
|
| 89 |
+
3. Be able to show exam schedules
|
| 90 |
+
4. Be able to read files
|
| 91 |
+
5. Be able to upload files (with passcode)
|
| 92 |
+
|
| 93 |
+
## Current Server Status
|
| 94 |
+
|
| 95 |
+
✅ Your MCP server is currently running at http://localhost:8000
|
| 96 |
+
✅ Sample documents have been created in E:\mpc-hackathon\data\documents
|
| 97 |
+
✅ Sample exams have been added to the database
|
| 98 |
+
|
| 99 |
+
Just add the config to Claude Desktop and restart it!
|
DEPLOYMENT.md
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Deployment Guide for Hugging Face Spaces
|
| 2 |
+
|
| 3 |
+
## Prerequisites
|
| 4 |
+
- Hugging Face account
|
| 5 |
+
- Hugging Face Spaces access
|
| 6 |
+
- Git installed locally
|
| 7 |
+
|
| 8 |
+
## Deployment Steps
|
| 9 |
+
|
| 10 |
+
### 1. Create a New Space on Hugging Face
|
| 11 |
+
1. Go to https://huggingface.co/spaces
|
| 12 |
+
2. Click "Create new Space"
|
| 13 |
+
3. Choose a name for your space
|
| 14 |
+
4. Select "Docker" as the SDK
|
| 15 |
+
5. Choose visibility (public/private)
|
| 16 |
+
6. Click "Create Space"
|
| 17 |
+
|
| 18 |
+
### 2. Configure Environment Variables
|
| 19 |
+
In your Hugging Face Space settings:
|
| 20 |
+
1. Go to Settings → Variables and Secrets
|
| 21 |
+
2. Add the following secrets:
|
| 22 |
+
- `GEMINI_API_KEY`: Your Google Gemini API key
|
| 23 |
+
- `UPLOAD_PASSCODE`: Your secure upload passcode
|
| 24 |
+
- `MCP_SERVER_URL`: The MCP backend URL (if external)
|
| 25 |
+
|
| 26 |
+
### 3. Prepare Your Repository
|
| 27 |
+
```bash
|
| 28 |
+
# Clone your HF Space repository
|
| 29 |
+
git clone https://huggingface.co/spaces/YOUR_USERNAME/YOUR_SPACE_NAME
|
| 30 |
+
cd YOUR_SPACE_NAME
|
| 31 |
+
|
| 32 |
+
# Copy all project files
|
| 33 |
+
cp -r /path/to/mpc-hackathon/* .
|
| 34 |
+
|
| 35 |
+
# Make sure Dockerfile is in root
|
| 36 |
+
ls Dockerfile
|
| 37 |
+
|
| 38 |
+
# Add all files
|
| 39 |
+
git add .
|
| 40 |
+
git commit -m "Initial deployment"
|
| 41 |
+
```
|
| 42 |
+
|
| 43 |
+
### 4. Deploy to Hugging Face Spaces
|
| 44 |
+
```bash
|
| 45 |
+
# Push to Hugging Face
|
| 46 |
+
git push origin main
|
| 47 |
+
```
|
| 48 |
+
|
| 49 |
+
### 5. Monitor Deployment
|
| 50 |
+
- Check the build logs in your Space
|
| 51 |
+
- The app will be available at: https://YOUR_USERNAME-YOUR_SPACE_NAME.hf.space
|
| 52 |
+
|
| 53 |
+
## Docker Configuration
|
| 54 |
+
|
| 55 |
+
The Dockerfile is configured to:
|
| 56 |
+
1. Build the Next.js application
|
| 57 |
+
2. Install Python backend dependencies
|
| 58 |
+
3. Run both frontend (port 3000) and backend (port 8000)
|
| 59 |
+
4. Create necessary data directories
|
| 60 |
+
5. Run as non-root user for security
|
| 61 |
+
|
| 62 |
+
## File Structure for Deployment
|
| 63 |
+
```
|
| 64 |
+
/
|
| 65 |
+
├── app/ # Next.js application
|
| 66 |
+
├── backend/ # Python MCP backend
|
| 67 |
+
├── public/ # Static assets
|
| 68 |
+
├── data/ # Data storage (created at runtime)
|
| 69 |
+
├── Dockerfile # Docker configuration
|
| 70 |
+
├── package.json # Node dependencies
|
| 71 |
+
├── requirements.txt # Python dependencies
|
| 72 |
+
├── next.config.mjs # Next.js configuration
|
| 73 |
+
└── .env # Environment variables (create from .env.example)
|
| 74 |
+
```
|
| 75 |
+
|
| 76 |
+
## Troubleshooting
|
| 77 |
+
|
| 78 |
+
### Build Failures
|
| 79 |
+
- Check Dockerfile syntax
|
| 80 |
+
- Verify all dependencies in package.json and requirements.txt
|
| 81 |
+
- Check HF Space logs for specific errors
|
| 82 |
+
|
| 83 |
+
### Runtime Issues
|
| 84 |
+
- Verify environment variables are set
|
| 85 |
+
- Check port configurations
|
| 86 |
+
- Ensure data directories have proper permissions
|
| 87 |
+
|
| 88 |
+
### Performance
|
| 89 |
+
- HF Spaces have resource limits
|
| 90 |
+
- Consider optimizing images and assets
|
| 91 |
+
- Use production builds
|
| 92 |
+
|
| 93 |
+
## Security Notes
|
| 94 |
+
- Never commit `.env` files with real credentials
|
| 95 |
+
- Use HF Spaces secrets for sensitive data
|
| 96 |
+
- The app runs as non-root user for security
|
| 97 |
+
- Upload passcode protects write operations
|
| 98 |
+
|
| 99 |
+
## Updates
|
| 100 |
+
To update your deployed app:
|
| 101 |
+
```bash
|
| 102 |
+
git add .
|
| 103 |
+
git commit -m "Update description"
|
| 104 |
+
git push origin main
|
| 105 |
+
```
|
| 106 |
+
|
| 107 |
+
The Space will automatically rebuild and redeploy.
|
Dockerfile
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Use Node.js 20 as the base image
|
| 2 |
+
FROM node:20-alpine AS base
|
| 3 |
+
|
| 4 |
+
# Install dependencies only when needed
|
| 5 |
+
FROM base AS deps
|
| 6 |
+
RUN apk add --no-cache libc6-compat
|
| 7 |
+
WORKDIR /app
|
| 8 |
+
|
| 9 |
+
# Install dependencies
|
| 10 |
+
COPY package.json package-lock.json* ./
|
| 11 |
+
RUN npm ci
|
| 12 |
+
|
| 13 |
+
# Build the application
|
| 14 |
+
FROM base AS builder
|
| 15 |
+
WORKDIR /app
|
| 16 |
+
COPY --from=deps /app/node_modules ./node_modules
|
| 17 |
+
COPY . .
|
| 18 |
+
|
| 19 |
+
# Set environment variables for build
|
| 20 |
+
ENV NEXT_TELEMETRY_DISABLED 1
|
| 21 |
+
|
| 22 |
+
# Build Next.js application
|
| 23 |
+
RUN npm run build
|
| 24 |
+
|
| 25 |
+
# Production image
|
| 26 |
+
FROM base AS runner
|
| 27 |
+
WORKDIR /app
|
| 28 |
+
|
| 29 |
+
ENV NODE_ENV production
|
| 30 |
+
ENV NEXT_TELEMETRY_DISABLED 1
|
| 31 |
+
|
| 32 |
+
# Create a non-root user
|
| 33 |
+
RUN addgroup --system --gid 1001 nodejs
|
| 34 |
+
RUN adduser --system --uid 1001 nextjs
|
| 35 |
+
|
| 36 |
+
# Copy built application
|
| 37 |
+
COPY --from=builder /app/public ./public
|
| 38 |
+
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
| 39 |
+
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
| 40 |
+
|
| 41 |
+
# Create data directory for file uploads
|
| 42 |
+
RUN mkdir -p /app/data/documents /app/data/public /app/data/exams
|
| 43 |
+
RUN chown -R nextjs:nodejs /app/data
|
| 44 |
+
|
| 45 |
+
# Install Python for MCP backend
|
| 46 |
+
RUN apk add --no-cache python3 py3-pip
|
| 47 |
+
|
| 48 |
+
# Copy Python backend
|
| 49 |
+
COPY --chown=nextjs:nodejs backend /app/backend
|
| 50 |
+
WORKDIR /app/backend
|
| 51 |
+
|
| 52 |
+
# Install Python dependencies
|
| 53 |
+
COPY requirements.txt .
|
| 54 |
+
RUN pip3 install --no-cache-dir -r requirements.txt
|
| 55 |
+
|
| 56 |
+
WORKDIR /app
|
| 57 |
+
|
| 58 |
+
# Switch to non-root user
|
| 59 |
+
USER nextjs
|
| 60 |
+
|
| 61 |
+
# Expose ports
|
| 62 |
+
EXPOSE 3000 8000
|
| 63 |
+
|
| 64 |
+
# Health check
|
| 65 |
+
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
| 66 |
+
CMD node -e "require('http').get('http://localhost:3000/api/health', (res) => process.exit(res.statusCode === 200 ? 0 : 1))"
|
| 67 |
+
|
| 68 |
+
# Start both services
|
| 69 |
+
CMD ["sh", "-c", "python3 backend/mcp_server.py & node server.js"]
|
Empc-hackathonbackendmcp_server.py
ADDED
|
File without changes
|
Empc-hackathonpublicbackground_readme.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
Please add your background.webp file to the public directory at E:\mpc-hackathon\public\background.webp
|
HACKATHON_README.md
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 🎓 Exam Hub - AI-Powered Academic Assistant
|
| 2 |
+
|
| 3 |
+
## 🏆 MCP Hackathon Submission
|
| 4 |
+
|
| 5 |
+
An innovative exam calendar and document management system that integrates with Claude through the Model Context Protocol (MCP), featuring a stunning Ubuntu-styled desktop interface.
|
| 6 |
+
|
| 7 |
+
## 🌟 What Makes This Special
|
| 8 |
+
|
| 9 |
+
### 🚀 First-Mover Innovation
|
| 10 |
+
- **Early MCP Adopter**: One of the first applications built with Anthropic's brand-new Model Context Protocol
|
| 11 |
+
- **AI-Native Design**: Not just AI-enabled, but designed from the ground up for AI interaction
|
| 12 |
+
- **Practical Application**: Solves real problems students face every day
|
| 13 |
+
|
| 14 |
+
### 💡 Key Features
|
| 15 |
+
|
| 16 |
+
#### 📚 Smart Document Management
|
| 17 |
+
- Upload and organize study materials
|
| 18 |
+
- AI-powered document search and extraction
|
| 19 |
+
- PDF and DOCX text extraction
|
| 20 |
+
- Full-text search within documents
|
| 21 |
+
- Automatic categorization by subject
|
| 22 |
+
|
| 23 |
+
#### 📅 Intelligent Exam Calendar
|
| 24 |
+
- Track all upcoming exams in one place
|
| 25 |
+
- Get AI-generated study schedules
|
| 26 |
+
- Find related documents for each exam
|
| 27 |
+
- Urgency-based exam prioritization
|
| 28 |
+
- Study session tracking
|
| 29 |
+
|
| 30 |
+
#### 🤖 Claude Integration (MCP)
|
| 31 |
+
- Natural language queries: "What exams do I have this week?"
|
| 32 |
+
- Smart document retrieval: "Find all chemistry notes"
|
| 33 |
+
- Study assistance: "Create a study plan for my math midterm"
|
| 34 |
+
- Document summarization: "Summarize the key points from chapter 5"
|
| 35 |
+
|
| 36 |
+
#### 🎨 Beautiful Ubuntu-Style Desktop UI
|
| 37 |
+
- Familiar desktop metaphor
|
| 38 |
+
- File manager with folder navigation
|
| 39 |
+
- Calendar application
|
| 40 |
+
- Matrix rain effect (because why not!)
|
| 41 |
+
- Responsive and intuitive design
|
| 42 |
+
|
| 43 |
+
## 🛠️ Technical Stack
|
| 44 |
+
|
| 45 |
+
### Frontend
|
| 46 |
+
- **Next.js 14** - React framework with App Router
|
| 47 |
+
- **Tailwind CSS** - Utility-first styling
|
| 48 |
+
- **Framer Motion** - Smooth animations
|
| 49 |
+
- **Phosphor Icons** - Beautiful icon set
|
| 50 |
+
|
| 51 |
+
### Backend
|
| 52 |
+
- **FastMCP** - Model Context Protocol server
|
| 53 |
+
- **Python** - Backend logic
|
| 54 |
+
- **SQLite** - Exam database
|
| 55 |
+
- **FastAPI** - REST API for demos
|
| 56 |
+
|
| 57 |
+
### AI Integration
|
| 58 |
+
- **MCP Tools** - 20+ custom tools for Claude
|
| 59 |
+
- **Document Intelligence** - PDF/DOCX extraction
|
| 60 |
+
- **Natural Language Interface** - Talk to your documents
|
| 61 |
+
|
| 62 |
+
## 🚦 Quick Start
|
| 63 |
+
|
| 64 |
+
### 1. Install Dependencies
|
| 65 |
+
|
| 66 |
+
```bash
|
| 67 |
+
# Frontend
|
| 68 |
+
npm install
|
| 69 |
+
|
| 70 |
+
# Backend
|
| 71 |
+
pip install -r requirements.txt
|
| 72 |
+
```
|
| 73 |
+
|
| 74 |
+
### 2. Run the Application
|
| 75 |
+
|
| 76 |
+
```bash
|
| 77 |
+
# Start Next.js UI (Terminal 1)
|
| 78 |
+
npm run dev
|
| 79 |
+
|
| 80 |
+
# Start MCP Server (Terminal 2)
|
| 81 |
+
cd backend
|
| 82 |
+
python mcp_server.py
|
| 83 |
+
|
| 84 |
+
# Optional: Start Demo API (Terminal 3)
|
| 85 |
+
cd backend
|
| 86 |
+
python demo_api.py
|
| 87 |
+
```
|
| 88 |
+
|
| 89 |
+
### 3. Access the Application
|
| 90 |
+
|
| 91 |
+
- **Web UI**: http://localhost:3000
|
| 92 |
+
- **MCP Server**: http://localhost:8000
|
| 93 |
+
- **Demo API**: http://localhost:8001/docs
|
| 94 |
+
|
| 95 |
+
## 🎮 Demo Instructions
|
| 96 |
+
|
| 97 |
+
### Web UI Demo
|
| 98 |
+
1. Open http://localhost:3000
|
| 99 |
+
2. Click on the File Manager icon
|
| 100 |
+
3. Browse through the virtual file system
|
| 101 |
+
4. Open the Calendar to see upcoming exams
|
| 102 |
+
|
| 103 |
+
### Claude Desktop Integration
|
| 104 |
+
1. Install Claude Desktop
|
| 105 |
+
2. The configuration has been added to: `%APPDATA%\Claude\claude_desktop_config.json`
|
| 106 |
+
3. Restart Claude Desktop
|
| 107 |
+
4. Try: "Show me all documents in the exam hub"
|
| 108 |
+
|
| 109 |
+
### API Demo
|
| 110 |
+
1. Open http://localhost:8001/docs
|
| 111 |
+
2. Try the interactive API documentation
|
| 112 |
+
3. Test endpoints like `/exams/upcoming` or `/documents`
|
| 113 |
+
|
| 114 |
+
## 📊 MCP Tools Available
|
| 115 |
+
|
| 116 |
+
### Document Tools (11 tools)
|
| 117 |
+
- `list_all_documents` - View all files
|
| 118 |
+
- `get_folder_structure` - Browse folders
|
| 119 |
+
- `search_documents` - Find files by name
|
| 120 |
+
- `extract_pdf_text` - Read PDFs
|
| 121 |
+
- `extract_docx_text` - Read Word docs
|
| 122 |
+
- `read_text_file` - Read text files
|
| 123 |
+
- `search_in_document` - Search within files
|
| 124 |
+
- `get_document_summary` - Get file previews
|
| 125 |
+
- `upload_file` - Upload new files (secured)
|
| 126 |
+
- `create_folder` - Create directories
|
| 127 |
+
- `delete_file` - Remove files
|
| 128 |
+
|
| 129 |
+
### Calendar Tools (10 tools)
|
| 130 |
+
- `get_all_exams` - List all exams
|
| 131 |
+
- `get_exams_this_week` - Current week
|
| 132 |
+
- `get_exams_next_week` - Next week
|
| 133 |
+
- `get_exams_by_month` - Monthly view
|
| 134 |
+
- `find_exam_by_subject` - Subject search
|
| 135 |
+
- `get_exams_by_date` - Specific date
|
| 136 |
+
- `get_upcoming_exams` - Next N days
|
| 137 |
+
- `search_related_documents` - Find study materials
|
| 138 |
+
- `get_study_schedule` - Study sessions
|
| 139 |
+
- `get_exam_statistics` - Analytics
|
| 140 |
+
|
| 141 |
+
## 🎯 Use Cases
|
| 142 |
+
|
| 143 |
+
### For Students
|
| 144 |
+
- "What exams are coming up this week?"
|
| 145 |
+
- "Find all my chemistry notes"
|
| 146 |
+
- "Create a study schedule for finals"
|
| 147 |
+
- "Summarize this PDF for me"
|
| 148 |
+
|
| 149 |
+
### For Educators
|
| 150 |
+
- Track student exam schedules
|
| 151 |
+
- Organize course materials
|
| 152 |
+
- Generate study guides
|
| 153 |
+
- Monitor academic calendars
|
| 154 |
+
|
| 155 |
+
## 🔒 Security
|
| 156 |
+
|
| 157 |
+
- **Passcode Protection**: Upload operations require authentication
|
| 158 |
+
- **Local Storage**: All data stays on your machine
|
| 159 |
+
- **Secure Design**: No external data transmission
|
| 160 |
+
|
| 161 |
+
## 🌍 Deployment Options
|
| 162 |
+
|
| 163 |
+
### Hugging Face Spaces
|
| 164 |
+
- Ready for deployment with Docker
|
| 165 |
+
- Persistent storage configured
|
| 166 |
+
- Environment variables set
|
| 167 |
+
|
| 168 |
+
### Local Installation
|
| 169 |
+
- Works on Windows, Mac, Linux
|
| 170 |
+
- No internet required
|
| 171 |
+
- Full privacy control
|
| 172 |
+
|
| 173 |
+
## 📈 Future Enhancements
|
| 174 |
+
|
| 175 |
+
- [ ] Mobile app version
|
| 176 |
+
- [ ] Collaborative study groups
|
| 177 |
+
- [ ] AI-generated practice exams
|
| 178 |
+
- [ ] Integration with university systems
|
| 179 |
+
- [ ] Voice commands
|
| 180 |
+
- [ ] Smart notifications
|
| 181 |
+
|
| 182 |
+
## 🏗️ Architecture
|
| 183 |
+
|
| 184 |
+
```
|
| 185 |
+
┌─────────────────┐ ┌─────────────────┐
|
| 186 |
+
│ Next.js UI │────▶│ REST API │
|
| 187 |
+
└─────────────────┘ └─────────────────┘
|
| 188 |
+
│ │
|
| 189 |
+
│ ▼
|
| 190 |
+
│ ┌─────────────────┐
|
| 191 |
+
└─────────────▶│ MCP Server │
|
| 192 |
+
└─────────────────┘
|
| 193 |
+
│
|
| 194 |
+
┌────────┴────────┐
|
| 195 |
+
▼ ▼
|
| 196 |
+
┌──────────┐ ┌──────────┐
|
| 197 |
+
│ Documents│ │ SQLite │
|
| 198 |
+
└──────────┘ └──────────┘
|
| 199 |
+
```
|
| 200 |
+
|
| 201 |
+
## 👏 Acknowledgments
|
| 202 |
+
|
| 203 |
+
- Built for the Anthropic MCP Hackathon
|
| 204 |
+
- Powered by Claude and the Model Context Protocol
|
| 205 |
+
- Ubuntu desktop design inspiration
|
| 206 |
+
|
| 207 |
+
## 📝 License
|
| 208 |
+
|
| 209 |
+
MIT License - Feel free to use and modify!
|
| 210 |
+
|
| 211 |
+
---
|
| 212 |
+
|
| 213 |
+
**Built with ❤️ for students everywhere**
|
| 214 |
+
|
| 215 |
+
*Making exam preparation smarter with AI*
|
MCP_SETUP_GUIDE.md
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# MCP Integration Setup Guide for Claude Desktop
|
| 2 |
+
|
| 3 |
+
## Quick Start
|
| 4 |
+
|
| 5 |
+
### 1. Locate Claude Desktop Config File
|
| 6 |
+
|
| 7 |
+
The Claude Desktop configuration file is located at:
|
| 8 |
+
- **Windows**: `%APPDATA%\Claude\claude_desktop_config.json`
|
| 9 |
+
- **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json`
|
| 10 |
+
- **Linux**: `~/.config/Claude/claude_desktop_config.json`
|
| 11 |
+
|
| 12 |
+
### 2. Add MCP Server Configuration
|
| 13 |
+
|
| 14 |
+
Open the config file and add this configuration:
|
| 15 |
+
|
| 16 |
+
```json
|
| 17 |
+
{
|
| 18 |
+
"mcpServers": {
|
| 19 |
+
"exam-hub": {
|
| 20 |
+
"command": "python",
|
| 21 |
+
"args": ["E:\\mpc-hackathon\\backend\\mcp_server.py"],
|
| 22 |
+
"env": {
|
| 23 |
+
"UPLOAD_PASSCODE": "exam-hub-secure-2025",
|
| 24 |
+
"DATA_DIR": "E:\\mpc-hackathon\\data"
|
| 25 |
+
}
|
| 26 |
+
}
|
| 27 |
+
}
|
| 28 |
+
}
|
| 29 |
+
```
|
| 30 |
+
|
| 31 |
+
**Note**: Adjust the path to match your actual project location.
|
| 32 |
+
|
| 33 |
+
### 3. Install Python Dependencies
|
| 34 |
+
|
| 35 |
+
Make sure you have Python installed and run:
|
| 36 |
+
|
| 37 |
+
```bash
|
| 38 |
+
cd E:\mpc-hackathon
|
| 39 |
+
pip install -r requirements.txt
|
| 40 |
+
```
|
| 41 |
+
|
| 42 |
+
### 4. Restart Claude Desktop
|
| 43 |
+
|
| 44 |
+
After updating the configuration:
|
| 45 |
+
1. Completely close Claude Desktop
|
| 46 |
+
2. Reopen Claude Desktop
|
| 47 |
+
3. The MCP server should start automatically
|
| 48 |
+
|
| 49 |
+
## Testing the MCP Integration
|
| 50 |
+
|
| 51 |
+
### Test Commands to Try in Claude Desktop
|
| 52 |
+
|
| 53 |
+
Once connected, try these commands in a new Claude Desktop conversation:
|
| 54 |
+
|
| 55 |
+
#### 1. Check Server Status
|
| 56 |
+
Ask: "Can you check the exam hub server status?"
|
| 57 |
+
- Claude should use the `get_server_info` tool
|
| 58 |
+
|
| 59 |
+
#### 2. List Documents
|
| 60 |
+
Ask: "What documents are available?"
|
| 61 |
+
- Claude should use the `list_all_documents` tool
|
| 62 |
+
|
| 63 |
+
#### 3. View Exam Schedule
|
| 64 |
+
Ask: "What exams are coming up this week?"
|
| 65 |
+
- Claude should use the `get_exams_this_week` tool
|
| 66 |
+
|
| 67 |
+
#### 4. Search for Documents
|
| 68 |
+
Ask: "Find all PDF files"
|
| 69 |
+
- Claude should use the `search_documents` tool with file_type="pdf"
|
| 70 |
+
|
| 71 |
+
#### 5. Get Exam Statistics
|
| 72 |
+
Ask: "Show me exam statistics"
|
| 73 |
+
- Claude should use the `get_exam_statistics` tool
|
| 74 |
+
|
| 75 |
+
#### 6. Read a Document
|
| 76 |
+
Ask: "Read the study_tips.md file"
|
| 77 |
+
- Claude should use the `read_text_file` tool
|
| 78 |
+
|
| 79 |
+
#### 7. Search Within Documents
|
| 80 |
+
Ask: "Search for 'exam' in all documents"
|
| 81 |
+
- Claude should use the `search_in_document` tool
|
| 82 |
+
|
| 83 |
+
#### 8. Upload a File (Requires Passcode)
|
| 84 |
+
Ask: "Upload a test file with content 'Hello World' as test.txt"
|
| 85 |
+
- Claude will ask for the passcode
|
| 86 |
+
- Provide: `exam-hub-secure-2025`
|
| 87 |
+
- Claude should use the `upload_file` tool
|
| 88 |
+
|
| 89 |
+
## Available MCP Tools
|
| 90 |
+
|
| 91 |
+
### Document Management Tools (No Passcode)
|
| 92 |
+
- `list_all_documents()` - List all files with metadata
|
| 93 |
+
- `get_folder_structure()` - View folder hierarchy
|
| 94 |
+
- `search_documents(query, file_type)` - Search files by name
|
| 95 |
+
- `extract_pdf_text(filename, max_pages)` - Extract PDF content
|
| 96 |
+
- `extract_docx_text(filename)` - Extract Word document content
|
| 97 |
+
- `read_text_file(filename)` - Read text files
|
| 98 |
+
- `search_in_document(filename, query)` - Search within files
|
| 99 |
+
- `get_document_summary(filename)` - Get file preview and metadata
|
| 100 |
+
|
| 101 |
+
### Document Management Tools (Passcode Required)
|
| 102 |
+
- `upload_file(filename, file_data, passcode)` - Upload new files
|
| 103 |
+
- `create_folder(folder_path, passcode)` - Create folders
|
| 104 |
+
- `delete_file(filename, passcode)` - Delete files
|
| 105 |
+
|
| 106 |
+
### Exam Calendar Tools
|
| 107 |
+
- `get_all_exams()` - List all exams
|
| 108 |
+
- `get_exams_this_week()` - Current week's exams
|
| 109 |
+
- `get_exams_next_week()` - Next week's exams
|
| 110 |
+
- `get_exams_by_month(year, month)` - Exams by month
|
| 111 |
+
- `find_exam_by_subject(subject)` - Search by subject
|
| 112 |
+
- `get_exams_by_date(date)` - Exams on specific date
|
| 113 |
+
- `get_upcoming_exams(days)` - Upcoming exams in N days
|
| 114 |
+
- `search_related_documents(subject)` - Find related study materials
|
| 115 |
+
- `get_study_schedule(exam_id)` - View study sessions
|
| 116 |
+
- `get_exam_statistics()` - Exam statistics and analytics
|
| 117 |
+
|
| 118 |
+
## Troubleshooting
|
| 119 |
+
|
| 120 |
+
### MCP Server Not Connecting
|
| 121 |
+
|
| 122 |
+
1. **Check Python Installation**:
|
| 123 |
+
```bash
|
| 124 |
+
python --version
|
| 125 |
+
```
|
| 126 |
+
Should show Python 3.8 or higher
|
| 127 |
+
|
| 128 |
+
2. **Check Dependencies**:
|
| 129 |
+
```bash
|
| 130 |
+
pip list | grep fastmcp
|
| 131 |
+
```
|
| 132 |
+
Should show fastmcp installed
|
| 133 |
+
|
| 134 |
+
3. **Test Server Manually**:
|
| 135 |
+
```bash
|
| 136 |
+
cd E:\mpc-hackathon\backend
|
| 137 |
+
python mcp_server.py
|
| 138 |
+
```
|
| 139 |
+
Should show "Uvicorn running on http://127.0.0.1:8000"
|
| 140 |
+
|
| 141 |
+
4. **Check Claude Desktop Logs**:
|
| 142 |
+
- Open Developer Tools in Claude Desktop (Ctrl+Shift+I or Cmd+Option+I)
|
| 143 |
+
- Look for MCP-related errors in the console
|
| 144 |
+
|
| 145 |
+
### Common Issues
|
| 146 |
+
|
| 147 |
+
**Issue**: "MCP server failed to start"
|
| 148 |
+
**Solution**: Check the Python path in the config file is correct
|
| 149 |
+
|
| 150 |
+
**Issue**: "No tools available"
|
| 151 |
+
**Solution**: Restart Claude Desktop after updating config
|
| 152 |
+
|
| 153 |
+
**Issue**: "Invalid passcode" when uploading
|
| 154 |
+
**Solution**: Check the UPLOAD_PASSCODE in your .env file matches what you're using
|
| 155 |
+
|
| 156 |
+
## Environment Variables
|
| 157 |
+
|
| 158 |
+
The MCP server uses these environment variables (set in `.env` file):
|
| 159 |
+
|
| 160 |
+
```env
|
| 161 |
+
UPLOAD_PASSCODE=exam-hub-secure-2025 # Required for upload operations
|
| 162 |
+
MCP_PORT=8000 # Server port (default: 8000)
|
| 163 |
+
DATA_DIR=./data # Data directory path
|
| 164 |
+
```
|
| 165 |
+
|
| 166 |
+
## Sample Data
|
| 167 |
+
|
| 168 |
+
The server automatically creates sample files on first run:
|
| 169 |
+
- `README.txt` - Welcome message
|
| 170 |
+
- `study_tips.md` - Study tips document
|
| 171 |
+
- `exam_schedule.txt` - Sample exam schedule
|
| 172 |
+
|
| 173 |
+
Sample exams are also added to the database:
|
| 174 |
+
- Mathematics Midterm (Feb 15)
|
| 175 |
+
- Physics Final (Feb 20)
|
| 176 |
+
- Computer Science Quiz (Feb 10)
|
| 177 |
+
- Chemistry Midterm (Feb 18)
|
| 178 |
+
- History Essay (Feb 25)
|
| 179 |
+
- Biology Lab Exam (Feb 22)
|
| 180 |
+
|
| 181 |
+
## Security Notes
|
| 182 |
+
|
| 183 |
+
- The upload passcode (`exam-hub-secure-2025`) protects write operations
|
| 184 |
+
- Change this passcode in production
|
| 185 |
+
- Files are stored locally in the `data/documents` directory
|
| 186 |
+
- The exam database is stored in `data/exams/exams.db`
|
| 187 |
+
|
| 188 |
+
## Next Steps
|
| 189 |
+
|
| 190 |
+
1. **Test Basic Operations**: Try the test commands above
|
| 191 |
+
2. **Upload Real Documents**: Use the upload tool with your study materials
|
| 192 |
+
3. **Customize Exam Data**: Add your actual exam schedule
|
| 193 |
+
4. **Integrate with Web UI**: The Next.js frontend can be updated to use the same data directory
|
| 194 |
+
|
| 195 |
+
## Support
|
| 196 |
+
|
| 197 |
+
If you encounter issues:
|
| 198 |
+
1. Check the logs in Claude Desktop's Developer Tools
|
| 199 |
+
2. Verify the MCP server is running manually
|
| 200 |
+
3. Ensure all paths in the config are absolute paths
|
| 201 |
+
4. Make sure Python and all dependencies are installed correctly
|
TEST_MCP_CONNECTION.md
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Testing MCP Connection in Claude
|
| 2 |
+
|
| 3 |
+
## Your MCP Server is Running!
|
| 4 |
+
|
| 5 |
+
The MCP server is currently running at: `http://localhost:8000/sse`
|
| 6 |
+
|
| 7 |
+
## How to Connect in Claude
|
| 8 |
+
|
| 9 |
+
Since you're using Claude in the browser, you can test the MCP tools directly here! The server is already running in the background.
|
| 10 |
+
|
| 11 |
+
## Test Commands to Try Right Now
|
| 12 |
+
|
| 13 |
+
Try asking me these questions to test the MCP integration:
|
| 14 |
+
|
| 15 |
+
### 1. Basic Server Check
|
| 16 |
+
"Can you check what tools are available on the exam hub server?"
|
| 17 |
+
|
| 18 |
+
### 2. List Documents
|
| 19 |
+
"Show me all documents in the exam hub"
|
| 20 |
+
|
| 21 |
+
### 3. View Exams
|
| 22 |
+
"What exams are scheduled this week?"
|
| 23 |
+
|
| 24 |
+
### 4. Read a File
|
| 25 |
+
"Read the study_tips.md file from the exam hub"
|
| 26 |
+
|
| 27 |
+
### 5. Search Documents
|
| 28 |
+
"Search for PDF files in the document repository"
|
| 29 |
+
|
| 30 |
+
### 6. Get Exam Statistics
|
| 31 |
+
"Show me statistics about upcoming exams"
|
| 32 |
+
|
| 33 |
+
### 7. Upload Test (Requires Passcode)
|
| 34 |
+
"Upload a test file called 'hello.txt' with content 'Hello from Claude!'"
|
| 35 |
+
(Use passcode: exam-hub-secure-2025)
|
| 36 |
+
|
| 37 |
+
## Server Details
|
| 38 |
+
|
| 39 |
+
- **Status**: Running ✅
|
| 40 |
+
- **URL**: http://localhost:8000
|
| 41 |
+
- **SSE Endpoint**: http://localhost:8000/sse
|
| 42 |
+
- **Data Directory**: E:\mpc-hackathon\data
|
| 43 |
+
- **Documents**: E:\mpc-hackathon\data\documents
|
| 44 |
+
- **Database**: E:\mpc-hackathon\data\exams\exams.db
|
| 45 |
+
|
| 46 |
+
## Available Tools Summary
|
| 47 |
+
|
| 48 |
+
### Document Tools (No Auth Required)
|
| 49 |
+
- list_all_documents
|
| 50 |
+
- get_folder_structure
|
| 51 |
+
- search_documents
|
| 52 |
+
- extract_pdf_text
|
| 53 |
+
- extract_docx_text
|
| 54 |
+
- read_text_file
|
| 55 |
+
- search_in_document
|
| 56 |
+
- get_document_summary
|
| 57 |
+
|
| 58 |
+
### Document Tools (Passcode Required)
|
| 59 |
+
- upload_file (passcode: exam-hub-secure-2025)
|
| 60 |
+
- create_folder
|
| 61 |
+
- delete_file
|
| 62 |
+
|
| 63 |
+
### Calendar/Exam Tools
|
| 64 |
+
- get_all_exams
|
| 65 |
+
- get_exams_this_week
|
| 66 |
+
- get_exams_next_week
|
| 67 |
+
- get_exams_by_month
|
| 68 |
+
- find_exam_by_subject
|
| 69 |
+
- get_exams_by_date
|
| 70 |
+
- get_upcoming_exams
|
| 71 |
+
- search_related_documents
|
| 72 |
+
- get_study_schedule
|
| 73 |
+
- get_exam_statistics
|
| 74 |
+
- get_server_info
|
| 75 |
+
|
| 76 |
+
The server is ready for testing! Just ask me to use any of these tools.
|
TTT_CLAUDE_DESKTOP_GUIDE.md
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Playing Tic Tac Toe with Claude via MCP
|
| 2 |
+
|
| 3 |
+
This guide explains how to play Tic Tac Toe with Claude Desktop using the Semsoon MCP server.
|
| 4 |
+
|
| 5 |
+
## Overview
|
| 6 |
+
|
| 7 |
+
The Tic Tac Toe game has been integrated into the **Semsoon MCP Server**. You can play directly through:
|
| 8 |
+
1. **Claude Desktop** (via MCP tools)
|
| 9 |
+
2. **Web UI** (browser interface)
|
| 10 |
+
|
| 11 |
+
Both interfaces connect to the same game backend, so you could even start a game in one and continue in the other!
|
| 12 |
+
|
| 13 |
+
---
|
| 14 |
+
|
| 15 |
+
## Playing via Claude Desktop (MCP)
|
| 16 |
+
|
| 17 |
+
### Prerequisites
|
| 18 |
+
|
| 19 |
+
1. **Start the Semsoon MCP Server:**
|
| 20 |
+
```bash
|
| 21 |
+
cd backend
|
| 22 |
+
python mcp_server.py
|
| 23 |
+
```
|
| 24 |
+
|
| 25 |
+
2. **Ensure Claude Desktop is configured** to connect to the Semsoon MCP server (see `CLAUDE_DESKTOP_SETUP.md`)
|
| 26 |
+
|
| 27 |
+
### How to Play
|
| 28 |
+
|
| 29 |
+
Once connected, you can play Tic Tac Toe by simply talking to Claude! Here are the available commands:
|
| 30 |
+
|
| 31 |
+
#### 1. Start a New Game
|
| 32 |
+
Just ask Claude to start a game:
|
| 33 |
+
```
|
| 34 |
+
"Let's play Tic Tac Toe!"
|
| 35 |
+
```
|
| 36 |
+
|
| 37 |
+
Or be more explicit:
|
| 38 |
+
```
|
| 39 |
+
"Start a new Tic Tac Toe game"
|
| 40 |
+
```
|
| 41 |
+
|
| 42 |
+
Claude will call `ttt_new_game()` and show you:
|
| 43 |
+
- A game ID
|
| 44 |
+
- The empty board
|
| 45 |
+
- Board position numbers (0-8)
|
| 46 |
+
|
| 47 |
+
#### 2. Make Your Move
|
| 48 |
+
Tell Claude where you want to place your X:
|
| 49 |
+
```
|
| 50 |
+
"I'll take position 4" (center)
|
| 51 |
+
"Place my X at position 0" (top-left)
|
| 52 |
+
"I choose 8" (bottom-right)
|
| 53 |
+
```
|
| 54 |
+
|
| 55 |
+
Claude will:
|
| 56 |
+
1. Place your X at the chosen position
|
| 57 |
+
2. Automatically make its own move (O)
|
| 58 |
+
3. Show you the updated board
|
| 59 |
+
4. Check for a winner or draw
|
| 60 |
+
|
| 61 |
+
#### 3. Continue Playing
|
| 62 |
+
Keep making moves until someone wins or it's a draw!
|
| 63 |
+
|
| 64 |
+
#### 4. Game Help
|
| 65 |
+
Ask Claude for help anytime:
|
| 66 |
+
```
|
| 67 |
+
"How do I play Tic Tac Toe?"
|
| 68 |
+
"Show me the board positions"
|
| 69 |
+
"What are the TTT commands?"
|
| 70 |
+
```
|
| 71 |
+
|
| 72 |
+
### Board Layout
|
| 73 |
+
|
| 74 |
+
```
|
| 75 |
+
0 | 1 | 2
|
| 76 |
+
---------
|
| 77 |
+
3 | 4 | 5
|
| 78 |
+
---------
|
| 79 |
+
6 | 7 | 8
|
| 80 |
+
```
|
| 81 |
+
|
| 82 |
+
- **Position 0**: Top-left
|
| 83 |
+
- **Position 4**: Center
|
| 84 |
+
- **Position 8**: Bottom-right
|
| 85 |
+
|
| 86 |
+
---
|
| 87 |
+
|
| 88 |
+
## Available MCP Tools
|
| 89 |
+
|
| 90 |
+
If you want to call the tools directly, here they are:
|
| 91 |
+
|
| 92 |
+
### `ttt_new_game()`
|
| 93 |
+
Start a new game. Returns a game ID and initial board.
|
| 94 |
+
|
| 95 |
+
### `ttt_make_move(game_id, position)`
|
| 96 |
+
Make your move and Claude responds automatically.
|
| 97 |
+
- `game_id`: The ID from `ttt_new_game()`
|
| 98 |
+
- `position`: 0-8 (see board layout above)
|
| 99 |
+
|
| 100 |
+
### `ttt_get_game_state(game_id)`
|
| 101 |
+
View the current state of a game.
|
| 102 |
+
|
| 103 |
+
### `ttt_list_games()`
|
| 104 |
+
List all active games.
|
| 105 |
+
|
| 106 |
+
### `ttt_claude_move(game_id)`
|
| 107 |
+
Make Claude play (if it's Claude's turn).
|
| 108 |
+
|
| 109 |
+
### `ttt_help()`
|
| 110 |
+
Get detailed help and instructions.
|
| 111 |
+
|
| 112 |
+
---
|
| 113 |
+
|
| 114 |
+
## Example Conversation
|
| 115 |
+
|
| 116 |
+
**You:** Let's play Tic Tac Toe!
|
| 117 |
+
|
| 118 |
+
**Claude:** *[Calls ttt_new_game()]*
|
| 119 |
+
Great! I've started a new game. You are X, and I'm O. You go first!
|
| 120 |
+
|
| 121 |
+
```
|
| 122 |
+
0 | 1 | 2
|
| 123 |
+
---------
|
| 124 |
+
3 | 4 | 5
|
| 125 |
+
---------
|
| 126 |
+
6 | 7 | 8
|
| 127 |
+
```
|
| 128 |
+
|
| 129 |
+
Where would you like to place your X?
|
| 130 |
+
|
| 131 |
+
**You:** I'll take the center, position 4
|
| 132 |
+
|
| 133 |
+
**Claude:** *[Calls ttt_make_move(game_id, 4)]*
|
| 134 |
+
You placed X at position 4 (center). I'll place O at position 0 (top-left).
|
| 135 |
+
|
| 136 |
+
```
|
| 137 |
+
O | 1 | 2
|
| 138 |
+
---------
|
| 139 |
+
3 | X | 5
|
| 140 |
+
---------
|
| 141 |
+
6 | 7 | 8
|
| 142 |
+
```
|
| 143 |
+
|
| 144 |
+
Your turn! Where would you like to go?
|
| 145 |
+
|
| 146 |
+
**You:** Position 8
|
| 147 |
+
|
| 148 |
+
**Claude:** *[Calls ttt_make_move(game_id, 8)]*
|
| 149 |
+
You placed X at position 8. I'll block you with O at position 2!
|
| 150 |
+
|
| 151 |
+
```
|
| 152 |
+
O | 1 | O
|
| 153 |
+
---------
|
| 154 |
+
3 | X | 5
|
| 155 |
+
---------
|
| 156 |
+
6 | 7 | X
|
| 157 |
+
```
|
| 158 |
+
|
| 159 |
+
And so on...
|
| 160 |
+
|
| 161 |
+
---
|
| 162 |
+
|
| 163 |
+
## Playing via Web UI
|
| 164 |
+
|
| 165 |
+
The TTT game is also available as a desktop app in your Web OS!
|
| 166 |
+
|
| 167 |
+
1. **Start the Web UI** (if not already running)
|
| 168 |
+
2. **Click the Tic Tac Toe icon** (pink game controller) on the desktop or dock
|
| 169 |
+
3. **Play directly in the window** - click any empty cell to make your move
|
| 170 |
+
4. Claude (AI) will automatically respond
|
| 171 |
+
|
| 172 |
+
The web version uses the same game engine but provides a visual interface with:
|
| 173 |
+
- Visual board with clickable cells
|
| 174 |
+
- Score tracking
|
| 175 |
+
- New game button
|
| 176 |
+
- Animated moves
|
| 177 |
+
|
| 178 |
+
---
|
| 179 |
+
|
| 180 |
+
## Backend Architecture
|
| 181 |
+
|
| 182 |
+
### Game State Storage
|
| 183 |
+
- Games are stored in-memory in `backend/ttt_game.py`
|
| 184 |
+
- Each game has a unique ID
|
| 185 |
+
- Game state includes: board, current player, winner, game status
|
| 186 |
+
|
| 187 |
+
### AI Algorithm
|
| 188 |
+
Claude uses the **Minimax algorithm** with alpha-beta pruning to play optimally. This means:
|
| 189 |
+
- Claude will never lose if it plays perfectly
|
| 190 |
+
- Best you can do is draw (if you play perfectly too)
|
| 191 |
+
- Good luck! 🎮
|
| 192 |
+
|
| 193 |
+
### API Endpoints (for Web UI)
|
| 194 |
+
- `POST /ttt/new` - Create new game
|
| 195 |
+
- `POST /ttt/{game_id}/move` - Make a move
|
| 196 |
+
- `GET /ttt/{game_id}/state` - Get game state
|
| 197 |
+
- `GET /ttt/games` - List all games
|
| 198 |
+
|
| 199 |
+
---
|
| 200 |
+
|
| 201 |
+
## Troubleshooting
|
| 202 |
+
|
| 203 |
+
### Claude doesn't have TTT tools
|
| 204 |
+
1. Make sure the Semsoon MCP server is running
|
| 205 |
+
2. Check Claude Desktop config includes Semsoon server
|
| 206 |
+
3. Restart Claude Desktop
|
| 207 |
+
4. Try: "What tools do you have available?" to verify
|
| 208 |
+
|
| 209 |
+
### Game not responding
|
| 210 |
+
1. Check if MCP server is still running
|
| 211 |
+
2. Look for errors in server console
|
| 212 |
+
3. Try starting a new game
|
| 213 |
+
|
| 214 |
+
### Can't find my game
|
| 215 |
+
- Games are stored in-memory, so they're lost if server restarts
|
| 216 |
+
- Use `ttt_list_games()` to see all active games
|
| 217 |
+
|
| 218 |
+
---
|
| 219 |
+
|
| 220 |
+
## Tips for Playing
|
| 221 |
+
|
| 222 |
+
1. **Center first** (position 4) is often a strong opening
|
| 223 |
+
2. **Watch for two in a row** - block Claude or complete your own line
|
| 224 |
+
3. **Control corners** - positions 0, 2, 6, 8 are strategic
|
| 225 |
+
4. **Think ahead** - Claude is using minimax, so play carefully!
|
| 226 |
+
|
| 227 |
+
---
|
| 228 |
+
|
| 229 |
+
## Integration Details
|
| 230 |
+
|
| 231 |
+
### File Structure
|
| 232 |
+
```
|
| 233 |
+
backend/
|
| 234 |
+
├── mcp_server.py # Main Semsoon MCP server
|
| 235 |
+
├── ttt_game.py # Game logic and AI
|
| 236 |
+
├── demo_api.py # REST API endpoints
|
| 237 |
+
└── tools/
|
| 238 |
+
└── ttt_tools.py # MCP tool registrations
|
| 239 |
+
```
|
| 240 |
+
|
| 241 |
+
### How It Works
|
| 242 |
+
1. You talk to Claude Desktop
|
| 243 |
+
2. Claude calls MCP tools (e.g., `ttt_make_move`)
|
| 244 |
+
3. Tools execute game logic in `ttt_game.py`
|
| 245 |
+
4. Results returned to Claude
|
| 246 |
+
5. Claude shows you the board and responds naturally
|
| 247 |
+
|
| 248 |
+
---
|
| 249 |
+
|
| 250 |
+
## Have Fun!
|
| 251 |
+
|
| 252 |
+
Enjoy playing Tic Tac Toe with Claude! It's a great way to:
|
| 253 |
+
- Take study breaks
|
| 254 |
+
- Test the MCP integration
|
| 255 |
+
- Challenge yourself against perfect AI play
|
| 256 |
+
|
| 257 |
+
Remember: You're X, Claude is O. May the best player win! 🏆
|
USE_NGROK.md
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Using ngrok for HTTPS Access
|
| 2 |
+
|
| 3 |
+
If you need an HTTPS endpoint for your MCP server:
|
| 4 |
+
|
| 5 |
+
## 1. Install ngrok
|
| 6 |
+
Download from: https://ngrok.com/download
|
| 7 |
+
|
| 8 |
+
## 2. Start your MCP server
|
| 9 |
+
```bash
|
| 10 |
+
cd backend
|
| 11 |
+
python mcp_server.py
|
| 12 |
+
```
|
| 13 |
+
|
| 14 |
+
## 3. Create HTTPS tunnel
|
| 15 |
+
```bash
|
| 16 |
+
ngrok http 8000
|
| 17 |
+
```
|
| 18 |
+
|
| 19 |
+
This will give you a public HTTPS URL like:
|
| 20 |
+
`https://abc123.ngrok.io`
|
| 21 |
+
|
| 22 |
+
## 4. Use the HTTPS URL
|
| 23 |
+
You can then use `https://abc123.ngrok.io/sse` as your MCP endpoint.
|
| 24 |
+
|
| 25 |
+
---
|
| 26 |
+
|
| 27 |
+
**BUT REMEMBER**: Claude's web interface doesn't support custom MCP connectors. You need Claude Desktop for MCP integration!
|
app/api/code/execute/route.ts
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from 'next/server'
|
| 2 |
+
import { exec } from 'child_process'
|
| 3 |
+
import { promisify } from 'util'
|
| 4 |
+
import fs from 'fs/promises'
|
| 5 |
+
import path from 'path'
|
| 6 |
+
import crypto from 'crypto'
|
| 7 |
+
|
| 8 |
+
const execAsync = promisify(exec)
|
| 9 |
+
|
| 10 |
+
// Session storage (in production, use a proper database)
|
| 11 |
+
const sessions = new Map<string, any>()
|
| 12 |
+
|
| 13 |
+
export async function POST(request: NextRequest) {
|
| 14 |
+
try {
|
| 15 |
+
const { sessionId, language, code, timestamp } = await request.json()
|
| 16 |
+
|
| 17 |
+
if (!sessionId || !language || !code) {
|
| 18 |
+
return NextResponse.json(
|
| 19 |
+
{ error: 'Missing required parameters' },
|
| 20 |
+
{ status: 400 }
|
| 21 |
+
)
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
// Create a temporary directory for this execution
|
| 25 |
+
const tempDir = path.join(process.cwd(), 'temp', sessionId)
|
| 26 |
+
await fs.mkdir(tempDir, { recursive: true })
|
| 27 |
+
|
| 28 |
+
let output = ''
|
| 29 |
+
let error = null
|
| 30 |
+
|
| 31 |
+
try {
|
| 32 |
+
switch (language) {
|
| 33 |
+
case 'python': {
|
| 34 |
+
const fileName = path.join(tempDir, `script_${timestamp}.py`)
|
| 35 |
+
await fs.writeFile(fileName, code)
|
| 36 |
+
|
| 37 |
+
try {
|
| 38 |
+
const result = await execAsync(`python "${fileName}"`, {
|
| 39 |
+
timeout: 10000, // 10 second timeout
|
| 40 |
+
maxBuffer: 1024 * 1024 // 1MB buffer
|
| 41 |
+
})
|
| 42 |
+
output = result.stdout
|
| 43 |
+
if (result.stderr) {
|
| 44 |
+
error = result.stderr
|
| 45 |
+
}
|
| 46 |
+
} catch (execError: any) {
|
| 47 |
+
error = execError.message || 'Execution failed'
|
| 48 |
+
output = execError.stdout || ''
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
// Clean up
|
| 52 |
+
await fs.unlink(fileName).catch(() => {})
|
| 53 |
+
break
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
case 'javascript':
|
| 57 |
+
case 'typescript': {
|
| 58 |
+
const fileName = path.join(tempDir, `script_${timestamp}.${language === 'typescript' ? 'ts' : 'js'}`)
|
| 59 |
+
await fs.writeFile(fileName, code)
|
| 60 |
+
|
| 61 |
+
try {
|
| 62 |
+
const command = language === 'typescript'
|
| 63 |
+
? `npx ts-node "${fileName}"`
|
| 64 |
+
: `node "${fileName}"`
|
| 65 |
+
|
| 66 |
+
const result = await execAsync(command, {
|
| 67 |
+
timeout: 10000,
|
| 68 |
+
maxBuffer: 1024 * 1024
|
| 69 |
+
})
|
| 70 |
+
output = result.stdout
|
| 71 |
+
if (result.stderr) {
|
| 72 |
+
error = result.stderr
|
| 73 |
+
}
|
| 74 |
+
} catch (execError: any) {
|
| 75 |
+
error = execError.message || 'Execution failed'
|
| 76 |
+
output = execError.stdout || ''
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
await fs.unlink(fileName).catch(() => {})
|
| 80 |
+
break
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
case 'react':
|
| 84 |
+
case 'flutter': {
|
| 85 |
+
// For React and Flutter, we'll return a message since they need build processes
|
| 86 |
+
output = `${language === 'react' ? 'React' : 'Flutter'} code saved successfully.
|
| 87 |
+
To run ${language} applications, use the integrated development server.`
|
| 88 |
+
|
| 89 |
+
// Save the code for later use
|
| 90 |
+
const fileName = path.join(tempDir, `app_${timestamp}.${language === 'react' ? 'jsx' : 'dart'}`)
|
| 91 |
+
await fs.writeFile(fileName, code)
|
| 92 |
+
break
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
case 'html':
|
| 96 |
+
case 'css': {
|
| 97 |
+
// HTML and CSS don't execute, just save them
|
| 98 |
+
const extension = language === 'html' ? 'html' : 'css'
|
| 99 |
+
const fileName = path.join(tempDir, `file_${timestamp}.${extension}`)
|
| 100 |
+
await fs.writeFile(fileName, code)
|
| 101 |
+
output = `${language.toUpperCase()} file saved successfully. Use the preview pane to see the result.`
|
| 102 |
+
break
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
default:
|
| 106 |
+
error = `Unsupported language: ${language}`
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
// Store in session
|
| 110 |
+
if (!sessions.has(sessionId)) {
|
| 111 |
+
sessions.set(sessionId, {
|
| 112 |
+
files: [],
|
| 113 |
+
executions: []
|
| 114 |
+
})
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
const session = sessions.get(sessionId)
|
| 118 |
+
session.executions.push({
|
| 119 |
+
language,
|
| 120 |
+
code,
|
| 121 |
+
output,
|
| 122 |
+
error,
|
| 123 |
+
timestamp
|
| 124 |
+
})
|
| 125 |
+
|
| 126 |
+
// Keep only last 50 executions
|
| 127 |
+
if (session.executions.length > 50) {
|
| 128 |
+
session.executions = session.executions.slice(-50)
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
return NextResponse.json({
|
| 132 |
+
output,
|
| 133 |
+
error,
|
| 134 |
+
timestamp,
|
| 135 |
+
sessionId
|
| 136 |
+
})
|
| 137 |
+
|
| 138 |
+
} catch (err: any) {
|
| 139 |
+
return NextResponse.json(
|
| 140 |
+
{ error: err.message || 'Execution failed' },
|
| 141 |
+
{ status: 500 }
|
| 142 |
+
)
|
| 143 |
+
} finally {
|
| 144 |
+
// Clean up temp directory after some time
|
| 145 |
+
setTimeout(async () => {
|
| 146 |
+
try {
|
| 147 |
+
await fs.rmdir(tempDir, { recursive: true })
|
| 148 |
+
} catch {}
|
| 149 |
+
}, 60000) // Clean after 1 minute
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
} catch (err: any) {
|
| 153 |
+
return NextResponse.json(
|
| 154 |
+
{ error: err.message || 'Request failed' },
|
| 155 |
+
{ status: 500 }
|
| 156 |
+
)
|
| 157 |
+
}
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
export async function GET(request: NextRequest) {
|
| 161 |
+
const searchParams = request.nextUrl.searchParams
|
| 162 |
+
const sessionId = searchParams.get('sessionId')
|
| 163 |
+
|
| 164 |
+
if (!sessionId) {
|
| 165 |
+
return NextResponse.json(
|
| 166 |
+
{ error: 'Session ID required' },
|
| 167 |
+
{ status: 400 }
|
| 168 |
+
)
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
const session = sessions.get(sessionId)
|
| 172 |
+
|
| 173 |
+
if (!session) {
|
| 174 |
+
return NextResponse.json({
|
| 175 |
+
sessionId,
|
| 176 |
+
executions: [],
|
| 177 |
+
files: []
|
| 178 |
+
})
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
return NextResponse.json({
|
| 182 |
+
sessionId,
|
| 183 |
+
executions: session.executions || [],
|
| 184 |
+
files: session.files || []
|
| 185 |
+
})
|
| 186 |
+
}
|
app/api/code/public/route.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from 'next/server'
|
| 2 |
+
import fs from 'fs/promises'
|
| 3 |
+
import path from 'path'
|
| 4 |
+
|
| 5 |
+
const PUBLIC_DIR = path.join(process.cwd(), 'public', 'shared-code')
|
| 6 |
+
|
| 7 |
+
// Initialize public directory
|
| 8 |
+
async function ensurePublicDir() {
|
| 9 |
+
await fs.mkdir(PUBLIC_DIR, { recursive: true })
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
export async function GET(request: NextRequest) {
|
| 13 |
+
try {
|
| 14 |
+
await ensurePublicDir()
|
| 15 |
+
|
| 16 |
+
// Read all public files
|
| 17 |
+
const files = await fs.readdir(PUBLIC_DIR)
|
| 18 |
+
const publicFiles = []
|
| 19 |
+
|
| 20 |
+
for (const file of files) {
|
| 21 |
+
if (file.endsWith('.json')) {
|
| 22 |
+
try {
|
| 23 |
+
const content = await fs.readFile(path.join(PUBLIC_DIR, file), 'utf-8')
|
| 24 |
+
const fileData = JSON.parse(content)
|
| 25 |
+
publicFiles.push(fileData)
|
| 26 |
+
} catch (err) {
|
| 27 |
+
console.error(`Error reading file ${file}:`, err)
|
| 28 |
+
}
|
| 29 |
+
}
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
// Sort by timestamp (newest first)
|
| 33 |
+
publicFiles.sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0))
|
| 34 |
+
|
| 35 |
+
return NextResponse.json(publicFiles)
|
| 36 |
+
} catch (err: any) {
|
| 37 |
+
return NextResponse.json(
|
| 38 |
+
{ error: 'Failed to load public files' },
|
| 39 |
+
{ status: 500 }
|
| 40 |
+
)
|
| 41 |
+
}
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
export async function POST(request: NextRequest) {
|
| 45 |
+
try {
|
| 46 |
+
const { sessionId, file } = await request.json()
|
| 47 |
+
|
| 48 |
+
if (!sessionId || !file) {
|
| 49 |
+
return NextResponse.json(
|
| 50 |
+
{ error: 'Missing required parameters' },
|
| 51 |
+
{ status: 400 }
|
| 52 |
+
)
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
await ensurePublicDir()
|
| 56 |
+
|
| 57 |
+
// Add metadata
|
| 58 |
+
const publicFile = {
|
| 59 |
+
...file,
|
| 60 |
+
id: `public_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
| 61 |
+
timestamp: Date.now(),
|
| 62 |
+
author: sessionId,
|
| 63 |
+
downloads: 0,
|
| 64 |
+
likes: 0
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
// Save to public directory
|
| 68 |
+
const fileName = `${publicFile.id}.json`
|
| 69 |
+
const filePath = path.join(PUBLIC_DIR, fileName)
|
| 70 |
+
|
| 71 |
+
await fs.writeFile(filePath, JSON.stringify(publicFile, null, 2))
|
| 72 |
+
|
| 73 |
+
return NextResponse.json({
|
| 74 |
+
success: true,
|
| 75 |
+
file: publicFile,
|
| 76 |
+
path: `/shared-code/${fileName}`
|
| 77 |
+
})
|
| 78 |
+
|
| 79 |
+
} catch (err: any) {
|
| 80 |
+
return NextResponse.json(
|
| 81 |
+
{ error: err.message || 'Failed to save public file' },
|
| 82 |
+
{ status: 500 }
|
| 83 |
+
)
|
| 84 |
+
}
|
| 85 |
+
}
|
app/api/code/save/route.ts
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from 'next/server'
|
| 2 |
+
import fs from 'fs'
|
| 3 |
+
import path from 'path'
|
| 4 |
+
|
| 5 |
+
export async function POST(request: NextRequest) {
|
| 6 |
+
try {
|
| 7 |
+
const body = await request.json()
|
| 8 |
+
const { sessionId, code, timestamp } = body
|
| 9 |
+
|
| 10 |
+
// Define the base path for saved code (accessible by MCP)
|
| 11 |
+
const baseDir = path.join(process.cwd(), 'data', 'vscode_sessions')
|
| 12 |
+
|
| 13 |
+
// Create directory if it doesn't exist
|
| 14 |
+
if (!fs.existsSync(baseDir)) {
|
| 15 |
+
fs.mkdirSync(baseDir, { recursive: true })
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
// Create session directory
|
| 19 |
+
const sessionDir = path.join(baseDir, sessionId)
|
| 20 |
+
if (!fs.existsSync(sessionDir)) {
|
| 21 |
+
fs.mkdirSync(sessionDir, { recursive: true })
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
// Save each file
|
| 25 |
+
for (const file of code) {
|
| 26 |
+
const filePath = path.join(sessionDir, file.name)
|
| 27 |
+
fs.writeFileSync(filePath, file.content, 'utf-8')
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
// Create metadata file
|
| 31 |
+
const metadata = {
|
| 32 |
+
sessionId,
|
| 33 |
+
timestamp,
|
| 34 |
+
files: code.map((f: any) => ({
|
| 35 |
+
name: f.name,
|
| 36 |
+
language: f.language,
|
| 37 |
+
size: f.content.length
|
| 38 |
+
})),
|
| 39 |
+
created: new Date().toISOString()
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
fs.writeFileSync(
|
| 43 |
+
path.join(sessionDir, 'metadata.json'),
|
| 44 |
+
JSON.stringify(metadata, null, 2),
|
| 45 |
+
'utf-8'
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
return NextResponse.json({
|
| 49 |
+
success: true,
|
| 50 |
+
message: 'Code saved successfully',
|
| 51 |
+
path: sessionDir,
|
| 52 |
+
sessionId
|
| 53 |
+
})
|
| 54 |
+
} catch (error) {
|
| 55 |
+
console.error('Error saving code:', error)
|
| 56 |
+
return NextResponse.json(
|
| 57 |
+
{ success: false, error: 'Failed to save code' },
|
| 58 |
+
{ status: 500 }
|
| 59 |
+
)
|
| 60 |
+
}
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
export async function GET(request: NextRequest) {
|
| 64 |
+
try {
|
| 65 |
+
const searchParams = request.nextUrl.searchParams
|
| 66 |
+
const sessionId = searchParams.get('sessionId')
|
| 67 |
+
|
| 68 |
+
if (!sessionId) {
|
| 69 |
+
// List all sessions
|
| 70 |
+
const baseDir = path.join(process.cwd(), 'data', 'vscode_sessions')
|
| 71 |
+
|
| 72 |
+
if (!fs.existsSync(baseDir)) {
|
| 73 |
+
return NextResponse.json({ sessions: [] })
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
const sessions = fs.readdirSync(baseDir)
|
| 77 |
+
.filter(dir => fs.statSync(path.join(baseDir, dir)).isDirectory())
|
| 78 |
+
.map(dir => {
|
| 79 |
+
const metadataPath = path.join(baseDir, dir, 'metadata.json')
|
| 80 |
+
if (fs.existsSync(metadataPath)) {
|
| 81 |
+
const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8'))
|
| 82 |
+
return {
|
| 83 |
+
sessionId: dir,
|
| 84 |
+
...metadata
|
| 85 |
+
}
|
| 86 |
+
}
|
| 87 |
+
return { sessionId: dir }
|
| 88 |
+
})
|
| 89 |
+
|
| 90 |
+
return NextResponse.json({ sessions })
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
// Get specific session
|
| 94 |
+
const sessionDir = path.join(process.cwd(), 'data', 'vscode_sessions', sessionId)
|
| 95 |
+
|
| 96 |
+
if (!fs.existsSync(sessionDir)) {
|
| 97 |
+
return NextResponse.json(
|
| 98 |
+
{ error: 'Session not found' },
|
| 99 |
+
{ status: 404 }
|
| 100 |
+
)
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
const files = fs.readdirSync(sessionDir)
|
| 104 |
+
.filter(file => file !== 'metadata.json')
|
| 105 |
+
.map(filename => {
|
| 106 |
+
const content = fs.readFileSync(path.join(sessionDir, filename), 'utf-8')
|
| 107 |
+
return {
|
| 108 |
+
name: filename,
|
| 109 |
+
content
|
| 110 |
+
}
|
| 111 |
+
})
|
| 112 |
+
|
| 113 |
+
const metadataPath = path.join(sessionDir, 'metadata.json')
|
| 114 |
+
const metadata = fs.existsSync(metadataPath)
|
| 115 |
+
? JSON.parse(fs.readFileSync(metadataPath, 'utf-8'))
|
| 116 |
+
: {}
|
| 117 |
+
|
| 118 |
+
return NextResponse.json({
|
| 119 |
+
sessionId,
|
| 120 |
+
files,
|
| 121 |
+
metadata
|
| 122 |
+
})
|
| 123 |
+
} catch (error) {
|
| 124 |
+
console.error('Error reading code:', error)
|
| 125 |
+
return NextResponse.json(
|
| 126 |
+
{ error: 'Failed to read code' },
|
| 127 |
+
{ status: 500 }
|
| 128 |
+
)
|
| 129 |
+
}
|
| 130 |
+
}
|
app/api/download/route.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from 'next/server'
|
| 2 |
+
import fs from 'fs'
|
| 3 |
+
import path from 'path'
|
| 4 |
+
|
| 5 |
+
const DATA_DIR = path.join(process.cwd(), 'data')
|
| 6 |
+
const DOCS_DIR = path.join(DATA_DIR, 'documents')
|
| 7 |
+
|
| 8 |
+
export async function GET(request: NextRequest) {
|
| 9 |
+
try {
|
| 10 |
+
const searchParams = request.nextUrl.searchParams
|
| 11 |
+
const filePath = searchParams.get('path')
|
| 12 |
+
const preview = searchParams.get('preview') === 'true'
|
| 13 |
+
|
| 14 |
+
if (!filePath) {
|
| 15 |
+
return NextResponse.json({ error: 'File path required' }, { status: 400 })
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
const fullPath = path.join(DOCS_DIR, filePath)
|
| 19 |
+
|
| 20 |
+
// Security check
|
| 21 |
+
if (!fullPath.startsWith(DOCS_DIR)) {
|
| 22 |
+
return NextResponse.json({ error: 'Invalid path' }, { status: 400 })
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
if (!fs.existsSync(fullPath)) {
|
| 26 |
+
return NextResponse.json({ error: 'File not found' }, { status: 404 })
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
const stats = fs.statSync(fullPath)
|
| 30 |
+
if (stats.isDirectory()) {
|
| 31 |
+
return NextResponse.json({ error: 'Cannot download directory' }, { status: 400 })
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
const fileBuffer = fs.readFileSync(fullPath)
|
| 35 |
+
const fileName = path.basename(filePath)
|
| 36 |
+
const ext = path.extname(fileName).toLowerCase()
|
| 37 |
+
|
| 38 |
+
// Determine content type
|
| 39 |
+
let contentType = 'application/octet-stream'
|
| 40 |
+
const mimeTypes: Record<string, string> = {
|
| 41 |
+
'.pdf': 'application/pdf',
|
| 42 |
+
'.doc': 'application/msword',
|
| 43 |
+
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
| 44 |
+
'.xls': 'application/vnd.ms-excel',
|
| 45 |
+
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
| 46 |
+
'.ppt': 'application/vnd.ms-powerpoint',
|
| 47 |
+
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
| 48 |
+
'.txt': 'text/plain',
|
| 49 |
+
'.md': 'text/markdown',
|
| 50 |
+
'.json': 'application/json',
|
| 51 |
+
'.html': 'text/html',
|
| 52 |
+
'.css': 'text/css',
|
| 53 |
+
'.js': 'text/javascript',
|
| 54 |
+
'.ts': 'text/typescript',
|
| 55 |
+
'.py': 'text/x-python',
|
| 56 |
+
'.java': 'text/x-java',
|
| 57 |
+
'.cpp': 'text/x-c++',
|
| 58 |
+
'.jpg': 'image/jpeg',
|
| 59 |
+
'.jpeg': 'image/jpeg',
|
| 60 |
+
'.png': 'image/png',
|
| 61 |
+
'.gif': 'image/gif',
|
| 62 |
+
'.svg': 'image/svg+xml',
|
| 63 |
+
'.mp3': 'audio/mpeg',
|
| 64 |
+
'.mp4': 'video/mp4',
|
| 65 |
+
'.zip': 'application/zip',
|
| 66 |
+
'.rar': 'application/x-rar-compressed'
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
if (mimeTypes[ext]) {
|
| 70 |
+
contentType = mimeTypes[ext]
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
const headers = new Headers({
|
| 74 |
+
'Content-Type': contentType,
|
| 75 |
+
'Content-Length': fileBuffer.length.toString(),
|
| 76 |
+
})
|
| 77 |
+
|
| 78 |
+
// If not preview mode, add download header
|
| 79 |
+
if (!preview) {
|
| 80 |
+
headers.set('Content-Disposition', `attachment; filename="${fileName}"`)
|
| 81 |
+
} else {
|
| 82 |
+
// For preview, use inline disposition for supported types
|
| 83 |
+
if (['application/pdf', 'text/plain', 'text/markdown', 'application/json'].includes(contentType) ||
|
| 84 |
+
contentType.startsWith('image/') || contentType.startsWith('text/')) {
|
| 85 |
+
headers.set('Content-Disposition', `inline; filename="${fileName}"`)
|
| 86 |
+
} else {
|
| 87 |
+
headers.set('Content-Disposition', `attachment; filename="${fileName}"`)
|
| 88 |
+
}
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
return new NextResponse(fileBuffer, { headers })
|
| 92 |
+
} catch (error) {
|
| 93 |
+
console.error('Error downloading file:', error)
|
| 94 |
+
return NextResponse.json(
|
| 95 |
+
{ error: 'Failed to download file' },
|
| 96 |
+
{ status: 500 }
|
| 97 |
+
)
|
| 98 |
+
}
|
| 99 |
+
}
|
app/api/files/route.ts
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from 'next/server'
|
| 2 |
+
import fs from 'fs'
|
| 3 |
+
import path from 'path'
|
| 4 |
+
|
| 5 |
+
const DATA_DIR = path.join(process.cwd(), 'data')
|
| 6 |
+
const DOCS_DIR = path.join(DATA_DIR, 'documents')
|
| 7 |
+
|
| 8 |
+
// Ensure directories exist
|
| 9 |
+
if (!fs.existsSync(DATA_DIR)) {
|
| 10 |
+
fs.mkdirSync(DATA_DIR, { recursive: true })
|
| 11 |
+
}
|
| 12 |
+
if (!fs.existsSync(DOCS_DIR)) {
|
| 13 |
+
fs.mkdirSync(DOCS_DIR, { recursive: true })
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
interface FileItem {
|
| 17 |
+
name: string
|
| 18 |
+
type: 'file' | 'folder'
|
| 19 |
+
size?: number
|
| 20 |
+
modified?: string
|
| 21 |
+
path: string
|
| 22 |
+
extension?: string
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
function getFileExtension(filename: string): string {
|
| 26 |
+
const ext = path.extname(filename).toLowerCase()
|
| 27 |
+
return ext.startsWith('.') ? ext.substring(1) : ext
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
function getFilesRecursively(dir: string, basePath: string = ''): FileItem[] {
|
| 31 |
+
const files: FileItem[] = []
|
| 32 |
+
|
| 33 |
+
try {
|
| 34 |
+
const items = fs.readdirSync(dir)
|
| 35 |
+
|
| 36 |
+
for (const item of items) {
|
| 37 |
+
// Skip hidden files and system files
|
| 38 |
+
if (item.startsWith('.') || item === 'exams.db') continue
|
| 39 |
+
|
| 40 |
+
const fullPath = path.join(dir, item)
|
| 41 |
+
const relativePath = path.join(basePath, item).replace(/\\/g, '/')
|
| 42 |
+
const stats = fs.statSync(fullPath)
|
| 43 |
+
|
| 44 |
+
if (stats.isDirectory()) {
|
| 45 |
+
files.push({
|
| 46 |
+
name: item,
|
| 47 |
+
type: 'folder',
|
| 48 |
+
path: relativePath,
|
| 49 |
+
modified: stats.mtime.toISOString()
|
| 50 |
+
})
|
| 51 |
+
// Recursively get files from subdirectories
|
| 52 |
+
const subFiles = getFilesRecursively(fullPath, relativePath)
|
| 53 |
+
files.push(...subFiles)
|
| 54 |
+
} else {
|
| 55 |
+
files.push({
|
| 56 |
+
name: item,
|
| 57 |
+
type: 'file',
|
| 58 |
+
size: stats.size,
|
| 59 |
+
modified: stats.mtime.toISOString(),
|
| 60 |
+
path: relativePath,
|
| 61 |
+
extension: getFileExtension(item)
|
| 62 |
+
})
|
| 63 |
+
}
|
| 64 |
+
}
|
| 65 |
+
} catch (error) {
|
| 66 |
+
console.error('Error reading directory:', error)
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
return files
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
export async function GET(request: NextRequest) {
|
| 73 |
+
const searchParams = request.nextUrl.searchParams
|
| 74 |
+
const folder = searchParams.get('folder') || ''
|
| 75 |
+
|
| 76 |
+
try {
|
| 77 |
+
const targetDir = path.join(DOCS_DIR, folder)
|
| 78 |
+
|
| 79 |
+
// Security check - prevent directory traversal
|
| 80 |
+
if (!targetDir.startsWith(DOCS_DIR)) {
|
| 81 |
+
return NextResponse.json({ error: 'Invalid path' }, { status: 400 })
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
if (!fs.existsSync(targetDir)) {
|
| 85 |
+
// If directory doesn't exist, create it
|
| 86 |
+
fs.mkdirSync(targetDir, { recursive: true })
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
const files = getFilesRecursively(targetDir, folder)
|
| 90 |
+
|
| 91 |
+
return NextResponse.json({
|
| 92 |
+
files,
|
| 93 |
+
currentPath: folder,
|
| 94 |
+
dataDir: DATA_DIR
|
| 95 |
+
})
|
| 96 |
+
} catch (error) {
|
| 97 |
+
console.error('Error listing files:', error)
|
| 98 |
+
return NextResponse.json(
|
| 99 |
+
{ error: 'Failed to list files' },
|
| 100 |
+
{ status: 500 }
|
| 101 |
+
)
|
| 102 |
+
}
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
// Create folder endpoint
|
| 106 |
+
export async function POST(request: NextRequest) {
|
| 107 |
+
try {
|
| 108 |
+
const body = await request.json()
|
| 109 |
+
const { folderName, parentPath = '' } = body
|
| 110 |
+
|
| 111 |
+
if (!folderName) {
|
| 112 |
+
return NextResponse.json({ error: 'Folder name required' }, { status: 400 })
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
const folderPath = path.join(DOCS_DIR, parentPath, folderName)
|
| 116 |
+
|
| 117 |
+
// Security check
|
| 118 |
+
if (!folderPath.startsWith(DOCS_DIR)) {
|
| 119 |
+
return NextResponse.json({ error: 'Invalid path' }, { status: 400 })
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
if (fs.existsSync(folderPath)) {
|
| 123 |
+
return NextResponse.json({ error: 'Folder already exists' }, { status: 400 })
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
fs.mkdirSync(folderPath, { recursive: true })
|
| 127 |
+
|
| 128 |
+
return NextResponse.json({
|
| 129 |
+
success: true,
|
| 130 |
+
path: path.join(parentPath, folderName).replace(/\\/g, '/')
|
| 131 |
+
})
|
| 132 |
+
} catch (error) {
|
| 133 |
+
console.error('Error creating folder:', error)
|
| 134 |
+
return NextResponse.json(
|
| 135 |
+
{ error: 'Failed to create folder' },
|
| 136 |
+
{ status: 500 }
|
| 137 |
+
)
|
| 138 |
+
}
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
// Delete file endpoint
|
| 142 |
+
export async function DELETE(request: NextRequest) {
|
| 143 |
+
try {
|
| 144 |
+
const searchParams = request.nextUrl.searchParams
|
| 145 |
+
const filePath = searchParams.get('path')
|
| 146 |
+
|
| 147 |
+
if (!filePath) {
|
| 148 |
+
return NextResponse.json({ error: 'File path required' }, { status: 400 })
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
const fullPath = path.join(DOCS_DIR, filePath)
|
| 152 |
+
|
| 153 |
+
// Security check
|
| 154 |
+
if (!fullPath.startsWith(DOCS_DIR)) {
|
| 155 |
+
return NextResponse.json({ error: 'Invalid path' }, { status: 400 })
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
if (!fs.existsSync(fullPath)) {
|
| 159 |
+
return NextResponse.json({ error: 'File not found' }, { status: 404 })
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
// Move to trash instead of permanent delete
|
| 163 |
+
const trashDir = path.join(DATA_DIR, '.trash')
|
| 164 |
+
if (!fs.existsSync(trashDir)) {
|
| 165 |
+
fs.mkdirSync(trashDir, { recursive: true })
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
const timestamp = Date.now()
|
| 169 |
+
const trashPath = path.join(trashDir, `${timestamp}_${path.basename(filePath)}`)
|
| 170 |
+
fs.renameSync(fullPath, trashPath)
|
| 171 |
+
|
| 172 |
+
return NextResponse.json({
|
| 173 |
+
success: true,
|
| 174 |
+
message: 'File moved to trash'
|
| 175 |
+
})
|
| 176 |
+
} catch (error) {
|
| 177 |
+
console.error('Error deleting file:', error)
|
| 178 |
+
return NextResponse.json(
|
| 179 |
+
{ error: 'Failed to delete file' },
|
| 180 |
+
{ status: 500 }
|
| 181 |
+
)
|
| 182 |
+
}
|
| 183 |
+
}
|
app/api/gemini/chat/route.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from 'next/server'
|
| 2 |
+
|
| 3 |
+
const GEMINI_API_URL = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent'
|
| 4 |
+
const GEMINI_API_KEY = process.env.GEMINI_API_KEY
|
| 5 |
+
|
| 6 |
+
export async function POST(request: NextRequest) {
|
| 7 |
+
try {
|
| 8 |
+
const { message, imageUrl, history, generateImage } = await request.json()
|
| 9 |
+
|
| 10 |
+
if (!GEMINI_API_KEY) {
|
| 11 |
+
return NextResponse.json(
|
| 12 |
+
{ error: 'Gemini API key not configured on server. Please set GEMINI_API_KEY environment variable.' },
|
| 13 |
+
{ status: 500 }
|
| 14 |
+
)
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
// For image generation requests
|
| 18 |
+
if (generateImage) {
|
| 19 |
+
// Note: Gemini doesn't directly generate images, but we can use a prompt to describe what an image should contain
|
| 20 |
+
// You would typically integrate with an image generation service like Stable Diffusion or DALL-E here
|
| 21 |
+
return NextResponse.json({
|
| 22 |
+
response: `I can describe the image for you: "${message}". For actual image generation, you would need to integrate with services like DALL-E or Stable Diffusion.`,
|
| 23 |
+
imageDescription: message,
|
| 24 |
+
note: 'Image generation requires integration with specialized services.'
|
| 25 |
+
})
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
// Prepare the request body for Gemini API
|
| 29 |
+
const requestBody: any = {
|
| 30 |
+
contents: [],
|
| 31 |
+
generationConfig: {
|
| 32 |
+
temperature: 0.9,
|
| 33 |
+
topK: 1,
|
| 34 |
+
topP: 1,
|
| 35 |
+
maxOutputTokens: 2048,
|
| 36 |
+
}
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
// Add conversation history if available
|
| 40 |
+
if (history && history.length > 0) {
|
| 41 |
+
history.forEach((msg: any) => {
|
| 42 |
+
requestBody.contents.push({
|
| 43 |
+
role: msg.role === 'user' ? 'user' : 'model',
|
| 44 |
+
parts: [{ text: msg.content }]
|
| 45 |
+
})
|
| 46 |
+
})
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
// Prepare the current message
|
| 50 |
+
const parts: any[] = []
|
| 51 |
+
|
| 52 |
+
if (message) {
|
| 53 |
+
parts.push({ text: message })
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
if (imageUrl) {
|
| 57 |
+
// Extract base64 data from data URL
|
| 58 |
+
const base64Data = imageUrl.split(',')[1]
|
| 59 |
+
parts.push({
|
| 60 |
+
inline_data: {
|
| 61 |
+
mime_type: imageUrl.split(':')[1].split(';')[0],
|
| 62 |
+
data: base64Data
|
| 63 |
+
}
|
| 64 |
+
})
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
requestBody.contents.push({
|
| 68 |
+
role: 'user',
|
| 69 |
+
parts: parts
|
| 70 |
+
})
|
| 71 |
+
|
| 72 |
+
// Make request to Gemini API
|
| 73 |
+
const response = await fetch(`${GEMINI_API_URL}?key=${GEMINI_API_KEY}`, {
|
| 74 |
+
method: 'POST',
|
| 75 |
+
headers: {
|
| 76 |
+
'Content-Type': 'application/json',
|
| 77 |
+
},
|
| 78 |
+
body: JSON.stringify(requestBody)
|
| 79 |
+
})
|
| 80 |
+
|
| 81 |
+
if (!response.ok) {
|
| 82 |
+
const errorText = await response.text()
|
| 83 |
+
console.error('Gemini API error:', errorText)
|
| 84 |
+
return NextResponse.json(
|
| 85 |
+
{ error: 'Failed to get response from Gemini', details: errorText },
|
| 86 |
+
{ status: response.status }
|
| 87 |
+
)
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
const data = await response.json()
|
| 91 |
+
|
| 92 |
+
if (!data.candidates || data.candidates.length === 0) {
|
| 93 |
+
return NextResponse.json(
|
| 94 |
+
{ error: 'No response generated' },
|
| 95 |
+
{ status: 500 }
|
| 96 |
+
)
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
const generatedText = data.candidates[0].content.parts[0].text
|
| 100 |
+
|
| 101 |
+
return NextResponse.json({
|
| 102 |
+
response: generatedText
|
| 103 |
+
})
|
| 104 |
+
|
| 105 |
+
} catch (error) {
|
| 106 |
+
console.error('Error in Gemini chat API:', error)
|
| 107 |
+
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'
|
| 108 |
+
return NextResponse.json(
|
| 109 |
+
{ error: 'Failed to process request', details: errorMessage },
|
| 110 |
+
{ status: 500 }
|
| 111 |
+
)
|
| 112 |
+
}
|
| 113 |
+
}
|
app/api/gemini/transcribe/route.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from 'next/server'
|
| 2 |
+
|
| 3 |
+
// Note: For audio transcription, we'll use Gemini's multimodal capabilities
|
| 4 |
+
// In production, you might want to use Google Cloud Speech-to-Text API for better accuracy
|
| 5 |
+
|
| 6 |
+
export async function POST(request: NextRequest) {
|
| 7 |
+
try {
|
| 8 |
+
const formData = await request.formData()
|
| 9 |
+
const audioFile = formData.get('audio') as File
|
| 10 |
+
const apiKey = formData.get('apiKey') as string
|
| 11 |
+
|
| 12 |
+
if (!apiKey) {
|
| 13 |
+
return NextResponse.json(
|
| 14 |
+
{ error: 'API key is required' },
|
| 15 |
+
{ status: 400 }
|
| 16 |
+
)
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
if (!audioFile) {
|
| 20 |
+
return NextResponse.json(
|
| 21 |
+
{ error: 'Audio file is required' },
|
| 22 |
+
{ status: 400 }
|
| 23 |
+
)
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
// Convert audio file to base64
|
| 27 |
+
const bytes = await audioFile.arrayBuffer()
|
| 28 |
+
const buffer = Buffer.from(bytes)
|
| 29 |
+
const base64Audio = buffer.toString('base64')
|
| 30 |
+
|
| 31 |
+
// Use Gemini API to transcribe
|
| 32 |
+
// Note: Gemini 1.5 Pro supports audio, but Flash might have limitations
|
| 33 |
+
const GEMINI_API_URL = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent'
|
| 34 |
+
|
| 35 |
+
const requestBody = {
|
| 36 |
+
contents: [{
|
| 37 |
+
role: 'user',
|
| 38 |
+
parts: [
|
| 39 |
+
{
|
| 40 |
+
text: 'Please transcribe the following audio accurately. Only return the transcription text, nothing else.'
|
| 41 |
+
},
|
| 42 |
+
{
|
| 43 |
+
inline_data: {
|
| 44 |
+
mime_type: audioFile.type || 'audio/wav',
|
| 45 |
+
data: base64Audio
|
| 46 |
+
}
|
| 47 |
+
}
|
| 48 |
+
]
|
| 49 |
+
}],
|
| 50 |
+
generationConfig: {
|
| 51 |
+
temperature: 0.1,
|
| 52 |
+
topK: 1,
|
| 53 |
+
topP: 1,
|
| 54 |
+
maxOutputTokens: 2048,
|
| 55 |
+
}
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
const response = await fetch(`${GEMINI_API_URL}?key=${apiKey}`, {
|
| 59 |
+
method: 'POST',
|
| 60 |
+
headers: {
|
| 61 |
+
'Content-Type': 'application/json',
|
| 62 |
+
},
|
| 63 |
+
body: JSON.stringify(requestBody)
|
| 64 |
+
})
|
| 65 |
+
|
| 66 |
+
if (!response.ok) {
|
| 67 |
+
const error = await response.json()
|
| 68 |
+
|
| 69 |
+
// If Gemini doesn't support audio, provide alternative solution
|
| 70 |
+
if (error.error?.message?.includes('audio') || error.error?.message?.includes('unsupported')) {
|
| 71 |
+
return NextResponse.json({
|
| 72 |
+
transcription: '[Audio transcription requires Gemini 1.5 Pro or Google Cloud Speech-to-Text API. Please upgrade your API access or use the chat feature with text input.]',
|
| 73 |
+
warning: 'Audio transcription not fully supported with current model'
|
| 74 |
+
})
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
throw new Error(error.error?.message || 'Failed to transcribe audio')
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
const data = await response.json()
|
| 81 |
+
const transcription = data.candidates?.[0]?.content?.parts?.[0]?.text || 'Could not transcribe audio'
|
| 82 |
+
|
| 83 |
+
return NextResponse.json({ transcription })
|
| 84 |
+
|
| 85 |
+
} catch (error) {
|
| 86 |
+
console.error('Transcription error:', error)
|
| 87 |
+
|
| 88 |
+
// Provide a helpful fallback message
|
| 89 |
+
return NextResponse.json({
|
| 90 |
+
transcription: '',
|
| 91 |
+
error: error instanceof Error ? error.message : 'Transcription failed. Note: Audio transcription requires Gemini 1.5 Pro or a dedicated speech-to-text API.'
|
| 92 |
+
}, { status: 500 })
|
| 93 |
+
}
|
| 94 |
+
}
|
app/api/public/route.ts
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from 'next/server'
|
| 2 |
+
import fs from 'fs'
|
| 3 |
+
import path from 'path'
|
| 4 |
+
|
| 5 |
+
const DATA_DIR = path.join(process.cwd(), 'data')
|
| 6 |
+
const PUBLIC_DIR = path.join(DATA_DIR, 'public')
|
| 7 |
+
|
| 8 |
+
// Ensure public directory exists
|
| 9 |
+
if (!fs.existsSync(PUBLIC_DIR)) {
|
| 10 |
+
fs.mkdirSync(PUBLIC_DIR, { recursive: true })
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
interface FileItem {
|
| 14 |
+
name: string
|
| 15 |
+
type: 'file' | 'folder'
|
| 16 |
+
size?: number
|
| 17 |
+
modified?: string
|
| 18 |
+
path: string
|
| 19 |
+
extension?: string
|
| 20 |
+
uploadedBy?: string
|
| 21 |
+
uploadedAt?: string
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
function getFileExtension(filename: string): string {
|
| 25 |
+
const ext = path.extname(filename).toLowerCase()
|
| 26 |
+
return ext.startsWith('.') ? ext.substring(1) : ext
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
export async function GET(request: NextRequest) {
|
| 30 |
+
const searchParams = request.nextUrl.searchParams
|
| 31 |
+
const folder = searchParams.get('folder') || ''
|
| 32 |
+
|
| 33 |
+
try {
|
| 34 |
+
const targetDir = path.join(PUBLIC_DIR, folder)
|
| 35 |
+
|
| 36 |
+
// Security check - prevent directory traversal
|
| 37 |
+
if (!targetDir.startsWith(PUBLIC_DIR)) {
|
| 38 |
+
return NextResponse.json({ error: 'Invalid path' }, { status: 400 })
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
if (!fs.existsSync(targetDir)) {
|
| 42 |
+
fs.mkdirSync(targetDir, { recursive: true })
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
const files: FileItem[] = []
|
| 46 |
+
const items = fs.readdirSync(targetDir)
|
| 47 |
+
|
| 48 |
+
for (const item of items) {
|
| 49 |
+
// Skip hidden files
|
| 50 |
+
if (item.startsWith('.')) continue
|
| 51 |
+
|
| 52 |
+
const fullPath = path.join(targetDir, item)
|
| 53 |
+
const relativePath = path.join(folder, item).replace(/\\/g, '/')
|
| 54 |
+
const stats = fs.statSync(fullPath)
|
| 55 |
+
|
| 56 |
+
if (stats.isDirectory()) {
|
| 57 |
+
files.push({
|
| 58 |
+
name: item,
|
| 59 |
+
type: 'folder',
|
| 60 |
+
path: relativePath,
|
| 61 |
+
modified: stats.mtime.toISOString()
|
| 62 |
+
})
|
| 63 |
+
} else {
|
| 64 |
+
// Try to read metadata if it exists
|
| 65 |
+
const metadataPath = fullPath + '.meta.json'
|
| 66 |
+
let metadata: any = {}
|
| 67 |
+
if (fs.existsSync(metadataPath)) {
|
| 68 |
+
try {
|
| 69 |
+
metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8'))
|
| 70 |
+
} catch (e) {
|
| 71 |
+
// Ignore metadata errors
|
| 72 |
+
}
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
files.push({
|
| 76 |
+
name: item,
|
| 77 |
+
type: 'file',
|
| 78 |
+
size: stats.size,
|
| 79 |
+
modified: stats.mtime.toISOString(),
|
| 80 |
+
path: relativePath,
|
| 81 |
+
extension: getFileExtension(item),
|
| 82 |
+
uploadedBy: metadata.uploadedBy || 'Anonymous',
|
| 83 |
+
uploadedAt: metadata.uploadedAt || stats.birthtime.toISOString()
|
| 84 |
+
})
|
| 85 |
+
}
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
return NextResponse.json({
|
| 89 |
+
files,
|
| 90 |
+
currentPath: folder,
|
| 91 |
+
isPublic: true
|
| 92 |
+
})
|
| 93 |
+
} catch (error) {
|
| 94 |
+
console.error('Error listing public files:', error)
|
| 95 |
+
return NextResponse.json(
|
| 96 |
+
{ error: 'Failed to list public files' },
|
| 97 |
+
{ status: 500 }
|
| 98 |
+
)
|
| 99 |
+
}
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
// Upload to public folder
|
| 103 |
+
export async function POST(request: NextRequest) {
|
| 104 |
+
try {
|
| 105 |
+
const formData = await request.formData()
|
| 106 |
+
const file = formData.get('file') as File
|
| 107 |
+
const folder = formData.get('folder') as string || ''
|
| 108 |
+
const uploadedBy = formData.get('uploadedBy') as string || 'Anonymous'
|
| 109 |
+
|
| 110 |
+
if (!file) {
|
| 111 |
+
return NextResponse.json({ error: 'No file provided' }, { status: 400 })
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
const targetDir = path.join(PUBLIC_DIR, folder)
|
| 115 |
+
|
| 116 |
+
// Security check
|
| 117 |
+
if (!targetDir.startsWith(PUBLIC_DIR)) {
|
| 118 |
+
return NextResponse.json({ error: 'Invalid path' }, { status: 400 })
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
if (!fs.existsSync(targetDir)) {
|
| 122 |
+
fs.mkdirSync(targetDir, { recursive: true })
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
const fileName = file.name
|
| 126 |
+
const filePath = path.join(targetDir, fileName)
|
| 127 |
+
|
| 128 |
+
// Check if file already exists
|
| 129 |
+
if (fs.existsSync(filePath)) {
|
| 130 |
+
// Add timestamp to filename
|
| 131 |
+
const timestamp = Date.now()
|
| 132 |
+
const ext = path.extname(fileName)
|
| 133 |
+
const baseName = path.basename(fileName, ext)
|
| 134 |
+
const newFileName = `${baseName}_${timestamp}${ext}`
|
| 135 |
+
const newFilePath = path.join(targetDir, newFileName)
|
| 136 |
+
|
| 137 |
+
const buffer = Buffer.from(await file.arrayBuffer())
|
| 138 |
+
fs.writeFileSync(newFilePath, buffer)
|
| 139 |
+
|
| 140 |
+
// Save metadata
|
| 141 |
+
const metadataPath = newFilePath + '.meta.json'
|
| 142 |
+
fs.writeFileSync(metadataPath, JSON.stringify({
|
| 143 |
+
uploadedBy,
|
| 144 |
+
uploadedAt: new Date().toISOString(),
|
| 145 |
+
originalName: fileName
|
| 146 |
+
}))
|
| 147 |
+
|
| 148 |
+
return NextResponse.json({
|
| 149 |
+
success: true,
|
| 150 |
+
message: 'File uploaded to public folder',
|
| 151 |
+
path: path.join(folder, newFileName).replace(/\\/g, '/'),
|
| 152 |
+
renamed: true
|
| 153 |
+
})
|
| 154 |
+
} else {
|
| 155 |
+
const buffer = Buffer.from(await file.arrayBuffer())
|
| 156 |
+
fs.writeFileSync(filePath, buffer)
|
| 157 |
+
|
| 158 |
+
// Save metadata
|
| 159 |
+
const metadataPath = filePath + '.meta.json'
|
| 160 |
+
fs.writeFileSync(metadataPath, JSON.stringify({
|
| 161 |
+
uploadedBy,
|
| 162 |
+
uploadedAt: new Date().toISOString()
|
| 163 |
+
}))
|
| 164 |
+
|
| 165 |
+
return NextResponse.json({
|
| 166 |
+
success: true,
|
| 167 |
+
message: 'File uploaded to public folder',
|
| 168 |
+
path: path.join(folder, fileName).replace(/\\/g, '/')
|
| 169 |
+
})
|
| 170 |
+
}
|
| 171 |
+
} catch (error) {
|
| 172 |
+
console.error('Error uploading to public folder:', error)
|
| 173 |
+
return NextResponse.json(
|
| 174 |
+
{ error: 'Failed to upload file' },
|
| 175 |
+
{ status: 500 }
|
| 176 |
+
)
|
| 177 |
+
}
|
| 178 |
+
}
|
app/api/upload/route.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from 'next/server'
|
| 2 |
+
import fs from 'fs'
|
| 3 |
+
import path from 'path'
|
| 4 |
+
|
| 5 |
+
const DATA_DIR = path.join(process.cwd(), 'data')
|
| 6 |
+
const DOCS_DIR = path.join(DATA_DIR, 'documents')
|
| 7 |
+
const MAX_FILE_SIZE = 50 * 1024 * 1024 // 50MB
|
| 8 |
+
|
| 9 |
+
export async function POST(request: NextRequest) {
|
| 10 |
+
try {
|
| 11 |
+
const formData = await request.formData()
|
| 12 |
+
const file = formData.get('file') as File
|
| 13 |
+
const folderPath = formData.get('folder') as string || ''
|
| 14 |
+
|
| 15 |
+
if (!file) {
|
| 16 |
+
return NextResponse.json({ error: 'No file provided' }, { status: 400 })
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
// Check file size
|
| 20 |
+
if (file.size > MAX_FILE_SIZE) {
|
| 21 |
+
return NextResponse.json(
|
| 22 |
+
{ error: `File size exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit` },
|
| 23 |
+
{ status: 400 }
|
| 24 |
+
)
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
// Sanitize filename
|
| 28 |
+
const fileName = file.name.replace(/[^a-zA-Z0-9._-]/g, '_')
|
| 29 |
+
const uploadDir = path.join(DOCS_DIR, folderPath)
|
| 30 |
+
const filePath = path.join(uploadDir, fileName)
|
| 31 |
+
|
| 32 |
+
// Security check
|
| 33 |
+
if (!filePath.startsWith(DOCS_DIR)) {
|
| 34 |
+
return NextResponse.json({ error: 'Invalid upload path' }, { status: 400 })
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
// Create directory if it doesn't exist
|
| 38 |
+
if (!fs.existsSync(uploadDir)) {
|
| 39 |
+
fs.mkdirSync(uploadDir, { recursive: true })
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
// Check if file already exists
|
| 43 |
+
if (fs.existsSync(filePath)) {
|
| 44 |
+
// Add timestamp to filename if it exists
|
| 45 |
+
const timestamp = Date.now()
|
| 46 |
+
const ext = path.extname(fileName)
|
| 47 |
+
const baseName = path.basename(fileName, ext)
|
| 48 |
+
const newFileName = `${baseName}_${timestamp}${ext}`
|
| 49 |
+
const newFilePath = path.join(uploadDir, newFileName)
|
| 50 |
+
|
| 51 |
+
// Convert file to buffer and save
|
| 52 |
+
const bytes = await file.arrayBuffer()
|
| 53 |
+
const buffer = Buffer.from(bytes)
|
| 54 |
+
fs.writeFileSync(newFilePath, buffer)
|
| 55 |
+
|
| 56 |
+
return NextResponse.json({
|
| 57 |
+
success: true,
|
| 58 |
+
message: 'File uploaded (renamed due to conflict)',
|
| 59 |
+
fileName: newFileName,
|
| 60 |
+
path: path.join(folderPath, newFileName).replace(/\\/g, '/'),
|
| 61 |
+
size: file.size
|
| 62 |
+
})
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
// Convert file to buffer and save
|
| 66 |
+
const bytes = await file.arrayBuffer()
|
| 67 |
+
const buffer = Buffer.from(bytes)
|
| 68 |
+
fs.writeFileSync(filePath, buffer)
|
| 69 |
+
|
| 70 |
+
return NextResponse.json({
|
| 71 |
+
success: true,
|
| 72 |
+
message: 'File uploaded successfully',
|
| 73 |
+
fileName: fileName,
|
| 74 |
+
path: path.join(folderPath, fileName).replace(/\\/g, '/'),
|
| 75 |
+
size: file.size
|
| 76 |
+
})
|
| 77 |
+
} catch (error) {
|
| 78 |
+
console.error('Error uploading file:', error)
|
| 79 |
+
return NextResponse.json(
|
| 80 |
+
{ error: 'Failed to upload file' },
|
| 81 |
+
{ status: 500 }
|
| 82 |
+
)
|
| 83 |
+
}
|
| 84 |
+
}
|
app/components/AboutModal.tsx
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import React, { useEffect, useState } from 'react'
|
| 4 |
+
import { X, Info, HardDrive, Cpu, Globe, Key } from '@phosphor-icons/react'
|
| 5 |
+
import { motion, AnimatePresence } from 'framer-motion'
|
| 6 |
+
|
| 7 |
+
interface AboutModalProps {
|
| 8 |
+
isOpen: boolean
|
| 9 |
+
onClose: () => void
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
export function AboutModal({ isOpen, onClose }: AboutModalProps) {
|
| 13 |
+
const [systemId, setSystemId] = useState<string>('')
|
| 14 |
+
const [publicPath, setPublicPath] = useState<string>('')
|
| 15 |
+
const [tempPath, setTempPath] = useState<string>('')
|
| 16 |
+
|
| 17 |
+
useEffect(() => {
|
| 18 |
+
// Generate unique system ID based on browser fingerprint
|
| 19 |
+
const generateSystemId = () => {
|
| 20 |
+
const nav = window.navigator
|
| 21 |
+
const screen = window.screen
|
| 22 |
+
const fingerprint = [
|
| 23 |
+
nav.userAgent,
|
| 24 |
+
nav.language,
|
| 25 |
+
screen.colorDepth,
|
| 26 |
+
screen.width,
|
| 27 |
+
screen.height,
|
| 28 |
+
new Date().getTimezoneOffset(),
|
| 29 |
+
nav.hardwareConcurrency,
|
| 30 |
+
nav.platform
|
| 31 |
+
].join('|')
|
| 32 |
+
|
| 33 |
+
// Create a simple hash
|
| 34 |
+
let hash = 0
|
| 35 |
+
for (let i = 0; i < fingerprint.length; i++) {
|
| 36 |
+
const char = fingerprint.charCodeAt(i)
|
| 37 |
+
hash = ((hash << 5) - hash) + char
|
| 38 |
+
hash = hash & hash // Convert to 32-bit integer
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
// Convert to hex and make it look like a system ID
|
| 42 |
+
const hexHash = Math.abs(hash).toString(16).toUpperCase()
|
| 43 |
+
return `REUBEN-${hexHash.substring(0, 4)}-${hexHash.substring(4, 8)}-${Date.now().toString(36).toUpperCase()}`
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
// Set system paths
|
| 47 |
+
setSystemId(generateSystemId())
|
| 48 |
+
setPublicPath('E:\\mpc-hackathon\\public')
|
| 49 |
+
setTempPath(`E:\\mpc-hackathon\\data\\temp_${Date.now()}`)
|
| 50 |
+
}, [])
|
| 51 |
+
|
| 52 |
+
if (!isOpen) return null
|
| 53 |
+
|
| 54 |
+
return (
|
| 55 |
+
<AnimatePresence>
|
| 56 |
+
{isOpen && (
|
| 57 |
+
<>
|
| 58 |
+
<div className="fixed inset-0 bg-black/50 z-[100]" onClick={onClose} />
|
| 59 |
+
<motion.div
|
| 60 |
+
initial={{ scale: 0.9, opacity: 0 }}
|
| 61 |
+
animate={{ scale: 1, opacity: 1 }}
|
| 62 |
+
exit={{ scale: 0.9, opacity: 0 }}
|
| 63 |
+
transition={{ duration: 0.2 }}
|
| 64 |
+
className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-[101]"
|
| 65 |
+
>
|
| 66 |
+
<div className="bg-white rounded-2xl shadow-2xl p-8 min-w-[500px] max-w-[600px]">
|
| 67 |
+
{/* Logo and Title */}
|
| 68 |
+
<div className="text-center mb-6">
|
| 69 |
+
<div className="w-24 h-24 mx-auto mb-4 bg-gradient-to-br from-purple-600 to-blue-600 rounded-2xl flex items-center justify-center">
|
| 70 |
+
<span className="text-white font-bold text-3xl">R</span>
|
| 71 |
+
</div>
|
| 72 |
+
<h1 className="text-3xl font-bold text-gray-900">Reuben OS</h1>
|
| 73 |
+
<p className="text-gray-500 mt-2">Advanced Desktop Environment</p>
|
| 74 |
+
<p className="text-sm text-gray-400 mt-1">Version 1.0.0</p>
|
| 75 |
+
</div>
|
| 76 |
+
|
| 77 |
+
{/* System Information */}
|
| 78 |
+
<div className="space-y-4 mb-6">
|
| 79 |
+
<div className="bg-gray-50 rounded-lg p-4">
|
| 80 |
+
<div className="flex items-center mb-2">
|
| 81 |
+
<Key className="text-purple-600 mr-2" size={20} weight="fill" />
|
| 82 |
+
<span className="font-semibold text-gray-700">System ID</span>
|
| 83 |
+
</div>
|
| 84 |
+
<code className="text-xs bg-white px-2 py-1 rounded border border-gray-200 text-gray-600 font-mono block">
|
| 85 |
+
{systemId || 'Generating...'}
|
| 86 |
+
</code>
|
| 87 |
+
</div>
|
| 88 |
+
|
| 89 |
+
<div className="bg-gray-50 rounded-lg p-4">
|
| 90 |
+
<div className="flex items-center mb-2">
|
| 91 |
+
<HardDrive className="text-blue-600 mr-2" size={20} weight="fill" />
|
| 92 |
+
<span className="font-semibold text-gray-700">Storage Paths</span>
|
| 93 |
+
</div>
|
| 94 |
+
<div className="space-y-2">
|
| 95 |
+
<div>
|
| 96 |
+
<span className="text-xs text-gray-500">Public:</span>
|
| 97 |
+
<code className="text-xs bg-white px-2 py-1 rounded border border-gray-200 text-gray-600 font-mono block mt-1">
|
| 98 |
+
{publicPath}
|
| 99 |
+
</code>
|
| 100 |
+
</div>
|
| 101 |
+
<div>
|
| 102 |
+
<span className="text-xs text-gray-500">Temp:</span>
|
| 103 |
+
<code className="text-xs bg-white px-2 py-1 rounded border border-gray-200 text-gray-600 font-mono block mt-1">
|
| 104 |
+
{tempPath}
|
| 105 |
+
</code>
|
| 106 |
+
</div>
|
| 107 |
+
</div>
|
| 108 |
+
</div>
|
| 109 |
+
|
| 110 |
+
<div className="bg-gray-50 rounded-lg p-4">
|
| 111 |
+
<div className="flex items-center mb-2">
|
| 112 |
+
<Cpu className="text-green-600 mr-2" size={20} weight="fill" />
|
| 113 |
+
<span className="font-semibold text-gray-700">Capabilities</span>
|
| 114 |
+
</div>
|
| 115 |
+
<div className="text-xs text-gray-600 space-y-1">
|
| 116 |
+
<div className="flex items-center">
|
| 117 |
+
<span className="w-2 h-2 bg-green-500 rounded-full mr-2"></span>
|
| 118 |
+
Python Code Execution (via MCP)
|
| 119 |
+
</div>
|
| 120 |
+
<div className="flex items-center">
|
| 121 |
+
<span className="w-2 h-2 bg-green-500 rounded-full mr-2"></span>
|
| 122 |
+
HTML/CSS/JS Preview
|
| 123 |
+
</div>
|
| 124 |
+
<div className="flex items-center">
|
| 125 |
+
<span className="w-2 h-2 bg-green-500 rounded-full mr-2"></span>
|
| 126 |
+
File Management System
|
| 127 |
+
</div>
|
| 128 |
+
<div className="flex items-center">
|
| 129 |
+
<span className="w-2 h-2 bg-green-500 rounded-full mr-2"></span>
|
| 130 |
+
Claude AI Integration
|
| 131 |
+
</div>
|
| 132 |
+
<div className="flex items-center">
|
| 133 |
+
<span className="w-2 h-2 bg-yellow-500 rounded-full mr-2"></span>
|
| 134 |
+
Flutter/Dart (Coming Soon)
|
| 135 |
+
</div>
|
| 136 |
+
</div>
|
| 137 |
+
</div>
|
| 138 |
+
|
| 139 |
+
<div className="bg-gray-50 rounded-lg p-4">
|
| 140 |
+
<div className="flex items-center mb-2">
|
| 141 |
+
<Globe className="text-indigo-600 mr-2" size={20} weight="fill" />
|
| 142 |
+
<span className="font-semibold text-gray-700">MCP Server</span>
|
| 143 |
+
</div>
|
| 144 |
+
<div className="text-xs text-gray-600">
|
| 145 |
+
<div>Status: <span className="text-green-600 font-semibold">Connected</span></div>
|
| 146 |
+
<div>Endpoint: <code className="font-mono">reuben-os</code></div>
|
| 147 |
+
<div>Tools: 25+ available</div>
|
| 148 |
+
</div>
|
| 149 |
+
</div>
|
| 150 |
+
</div>
|
| 151 |
+
|
| 152 |
+
{/* Footer */}
|
| 153 |
+
<div className="text-center text-xs text-gray-400 mb-4">
|
| 154 |
+
<p>© 2024 Reuben OS. All rights reserved.</p>
|
| 155 |
+
<p className="mt-1">Built with Next.js, React, and MCP Technology</p>
|
| 156 |
+
</div>
|
| 157 |
+
|
| 158 |
+
{/* Close Button */}
|
| 159 |
+
<button
|
| 160 |
+
onClick={onClose}
|
| 161 |
+
className="w-full bg-gradient-to-r from-purple-600 to-blue-600 text-white py-2 rounded-lg font-semibold hover:opacity-90 transition-opacity"
|
| 162 |
+
>
|
| 163 |
+
Close
|
| 164 |
+
</button>
|
| 165 |
+
|
| 166 |
+
{/* X button in corner */}
|
| 167 |
+
<button
|
| 168 |
+
onClick={onClose}
|
| 169 |
+
className="absolute top-4 right-4 text-gray-400 hover:text-gray-600 transition-colors"
|
| 170 |
+
>
|
| 171 |
+
<X size={20} weight="bold" />
|
| 172 |
+
</button>
|
| 173 |
+
</div>
|
| 174 |
+
</motion.div>
|
| 175 |
+
</>
|
| 176 |
+
)}
|
| 177 |
+
</AnimatePresence>
|
| 178 |
+
)
|
| 179 |
+
}
|
app/components/BackgroundSelector.tsx
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import React, { useState, useRef } from 'react'
|
| 4 |
+
import { motion, AnimatePresence } from 'framer-motion'
|
| 5 |
+
import { X, Upload, Image, Check } from '@phosphor-icons/react'
|
| 6 |
+
|
| 7 |
+
interface BackgroundSelectorProps {
|
| 8 |
+
isOpen: boolean
|
| 9 |
+
onClose: () => void
|
| 10 |
+
onSelectBackground: (background: string | File) => void
|
| 11 |
+
currentBackground: string
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
const presetBackgrounds = [
|
| 15 |
+
{
|
| 16 |
+
id: 'gradient-purple',
|
| 17 |
+
name: 'Ubuntu Purple',
|
| 18 |
+
style: 'linear-gradient(135deg, #77216F 0%, #5E2750 50%, #2C001E 100%)'
|
| 19 |
+
},
|
| 20 |
+
{
|
| 21 |
+
id: 'gradient-blue',
|
| 22 |
+
name: 'Ocean Blue',
|
| 23 |
+
style: 'linear-gradient(135deg, #1e3c72 0%, #2a5298 50%, #7e8ba3 100%)'
|
| 24 |
+
},
|
| 25 |
+
{
|
| 26 |
+
id: 'gradient-green',
|
| 27 |
+
name: 'Forest Green',
|
| 28 |
+
style: 'linear-gradient(135deg, #134e5e 0%, #71b280 50%, #a8e063 100%)'
|
| 29 |
+
},
|
| 30 |
+
{
|
| 31 |
+
id: 'gradient-orange',
|
| 32 |
+
name: 'Sunset Orange',
|
| 33 |
+
style: 'linear-gradient(135deg, #ff512f 0%, #dd2476 50%, #f09819 100%)'
|
| 34 |
+
},
|
| 35 |
+
{
|
| 36 |
+
id: 'gradient-dark',
|
| 37 |
+
name: 'Dark Mode',
|
| 38 |
+
style: 'linear-gradient(135deg, #0f0f0f 0%, #1a1a1a 50%, #2d2d2d 100%)'
|
| 39 |
+
},
|
| 40 |
+
{
|
| 41 |
+
id: 'gradient-cosmic',
|
| 42 |
+
name: 'Cosmic',
|
| 43 |
+
style: 'linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%)'
|
| 44 |
+
}
|
| 45 |
+
]
|
| 46 |
+
|
| 47 |
+
export function BackgroundSelector({
|
| 48 |
+
isOpen,
|
| 49 |
+
onClose,
|
| 50 |
+
onSelectBackground,
|
| 51 |
+
currentBackground
|
| 52 |
+
}: BackgroundSelectorProps) {
|
| 53 |
+
const [selectedTab, setSelectedTab] = useState<'presets' | 'upload'>('presets')
|
| 54 |
+
const [uploadedImage, setUploadedImage] = useState<string | null>(null)
|
| 55 |
+
const fileInputRef = useRef<HTMLInputElement>(null)
|
| 56 |
+
|
| 57 |
+
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
| 58 |
+
const file = event.target.files?.[0]
|
| 59 |
+
if (file) {
|
| 60 |
+
const reader = new FileReader()
|
| 61 |
+
reader.onload = (e) => {
|
| 62 |
+
const result = e.target?.result as string
|
| 63 |
+
setUploadedImage(result)
|
| 64 |
+
onSelectBackground(file)
|
| 65 |
+
}
|
| 66 |
+
reader.readAsDataURL(file)
|
| 67 |
+
}
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
return (
|
| 71 |
+
<AnimatePresence>
|
| 72 |
+
{isOpen && (
|
| 73 |
+
<>
|
| 74 |
+
{/* Backdrop */}
|
| 75 |
+
<motion.div
|
| 76 |
+
initial={{ opacity: 0 }}
|
| 77 |
+
animate={{ opacity: 1 }}
|
| 78 |
+
exit={{ opacity: 0 }}
|
| 79 |
+
onClick={onClose}
|
| 80 |
+
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50"
|
| 81 |
+
/>
|
| 82 |
+
|
| 83 |
+
{/* Modal */}
|
| 84 |
+
<motion.div
|
| 85 |
+
initial={{ scale: 0.9, opacity: 0 }}
|
| 86 |
+
animate={{ scale: 1, opacity: 1 }}
|
| 87 |
+
exit={{ scale: 0.9, opacity: 0 }}
|
| 88 |
+
transition={{ type: "spring", damping: 20, stiffness: 300 }}
|
| 89 |
+
className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[90%] max-w-2xl bg-[#2C2C2C]/95 backdrop-blur-md rounded-xl shadow-2xl z-50 border border-white/10"
|
| 90 |
+
>
|
| 91 |
+
{/* Header */}
|
| 92 |
+
<div className="flex items-center justify-between p-6 border-b border-white/10">
|
| 93 |
+
<h2 className="text-xl font-semibold text-white">Change Desktop Background</h2>
|
| 94 |
+
<button
|
| 95 |
+
onClick={onClose}
|
| 96 |
+
className="text-gray-400 hover:text-white transition-colors p-1 hover:bg-white/10 rounded-lg"
|
| 97 |
+
>
|
| 98 |
+
<X size={20} />
|
| 99 |
+
</button>
|
| 100 |
+
</div>
|
| 101 |
+
|
| 102 |
+
{/* Tabs */}
|
| 103 |
+
<div className="flex border-b border-white/10">
|
| 104 |
+
<button
|
| 105 |
+
onClick={() => setSelectedTab('presets')}
|
| 106 |
+
className={`flex-1 px-4 py-3 text-sm font-medium transition-colors ${
|
| 107 |
+
selectedTab === 'presets'
|
| 108 |
+
? 'text-white border-b-2 border-blue-500'
|
| 109 |
+
: 'text-gray-400 hover:text-white'
|
| 110 |
+
}`}
|
| 111 |
+
>
|
| 112 |
+
Gallery
|
| 113 |
+
</button>
|
| 114 |
+
<button
|
| 115 |
+
onClick={() => setSelectedTab('upload')}
|
| 116 |
+
className={`flex-1 px-4 py-3 text-sm font-medium transition-colors ${
|
| 117 |
+
selectedTab === 'upload'
|
| 118 |
+
? 'text-white border-b-2 border-blue-500'
|
| 119 |
+
: 'text-gray-400 hover:text-white'
|
| 120 |
+
}`}
|
| 121 |
+
>
|
| 122 |
+
Upload Image
|
| 123 |
+
</button>
|
| 124 |
+
</div>
|
| 125 |
+
|
| 126 |
+
{/* Content */}
|
| 127 |
+
<div className="p-6">
|
| 128 |
+
{selectedTab === 'presets' ? (
|
| 129 |
+
<div className="grid grid-cols-3 gap-4">
|
| 130 |
+
{presetBackgrounds.map((bg) => (
|
| 131 |
+
<button
|
| 132 |
+
key={bg.id}
|
| 133 |
+
onClick={() => onSelectBackground(bg.id)}
|
| 134 |
+
className={`relative aspect-video rounded-lg overflow-hidden border-2 transition-all ${
|
| 135 |
+
currentBackground === bg.id
|
| 136 |
+
? 'border-blue-500 scale-105'
|
| 137 |
+
: 'border-white/20 hover:border-white/40'
|
| 138 |
+
}`}
|
| 139 |
+
>
|
| 140 |
+
<div
|
| 141 |
+
className="w-full h-full"
|
| 142 |
+
style={{ background: bg.style }}
|
| 143 |
+
/>
|
| 144 |
+
{currentBackground === bg.id && (
|
| 145 |
+
<div className="absolute top-2 right-2 w-6 h-6 bg-blue-500 rounded-full flex items-center justify-center">
|
| 146 |
+
<Check size={14} weight="bold" className="text-white" />
|
| 147 |
+
</div>
|
| 148 |
+
)}
|
| 149 |
+
<div className="absolute bottom-0 left-0 right-0 bg-black/50 backdrop-blur-sm px-2 py-1">
|
| 150 |
+
<span className="text-xs text-white">{bg.name}</span>
|
| 151 |
+
</div>
|
| 152 |
+
</button>
|
| 153 |
+
))}
|
| 154 |
+
</div>
|
| 155 |
+
) : (
|
| 156 |
+
<div className="flex flex-col items-center justify-center py-12">
|
| 157 |
+
<input
|
| 158 |
+
ref={fileInputRef}
|
| 159 |
+
type="file"
|
| 160 |
+
accept="image/*"
|
| 161 |
+
onChange={handleFileUpload}
|
| 162 |
+
className="hidden"
|
| 163 |
+
/>
|
| 164 |
+
|
| 165 |
+
{uploadedImage ? (
|
| 166 |
+
<div className="relative w-full max-w-md">
|
| 167 |
+
<img
|
| 168 |
+
src={uploadedImage}
|
| 169 |
+
alt="Uploaded background"
|
| 170 |
+
className="w-full h-48 object-cover rounded-lg"
|
| 171 |
+
/>
|
| 172 |
+
<button
|
| 173 |
+
onClick={() => fileInputRef.current?.click()}
|
| 174 |
+
className="mt-4 w-full px-4 py-2 bg-blue-500 hover:bg-blue-600 text-white rounded-lg transition-colors"
|
| 175 |
+
>
|
| 176 |
+
Choose Different Image
|
| 177 |
+
</button>
|
| 178 |
+
</div>
|
| 179 |
+
) : (
|
| 180 |
+
<>
|
| 181 |
+
<div
|
| 182 |
+
onClick={() => fileInputRef.current?.click()}
|
| 183 |
+
className="w-32 h-32 border-2 border-dashed border-white/30 rounded-lg flex flex-col items-center justify-center cursor-pointer hover:border-white/50 transition-colors"
|
| 184 |
+
>
|
| 185 |
+
<Upload size={32} className="text-gray-400 mb-2" />
|
| 186 |
+
<span className="text-sm text-gray-400">Click to upload</span>
|
| 187 |
+
</div>
|
| 188 |
+
<p className="mt-4 text-sm text-gray-400">
|
| 189 |
+
Supported formats: JPG, PNG, WEBP, GIF
|
| 190 |
+
</p>
|
| 191 |
+
</>
|
| 192 |
+
)}
|
| 193 |
+
</div>
|
| 194 |
+
)}
|
| 195 |
+
</div>
|
| 196 |
+
|
| 197 |
+
{/* Footer */}
|
| 198 |
+
<div className="p-6 pt-0 flex gap-3 justify-end">
|
| 199 |
+
<button
|
| 200 |
+
onClick={onClose}
|
| 201 |
+
className="px-4 py-2 text-gray-300 hover:text-white transition-colors"
|
| 202 |
+
>
|
| 203 |
+
Cancel
|
| 204 |
+
</button>
|
| 205 |
+
<button
|
| 206 |
+
onClick={onClose}
|
| 207 |
+
className="px-4 py-2 bg-gradient-to-r from-blue-500 to-purple-500 text-white rounded-lg font-medium hover:from-blue-600 hover:to-purple-600 transition-all"
|
| 208 |
+
>
|
| 209 |
+
Apply
|
| 210 |
+
</button>
|
| 211 |
+
</div>
|
| 212 |
+
</motion.div>
|
| 213 |
+
</>
|
| 214 |
+
)}
|
| 215 |
+
</AnimatePresence>
|
| 216 |
+
)
|
| 217 |
+
}
|
app/components/Calendar.tsx
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import React, { useState } from 'react'
|
| 4 |
+
import { X, Minus, Square, CaretLeft, CaretRight } from '@phosphor-icons/react'
|
| 5 |
+
import { motion } from 'framer-motion'
|
| 6 |
+
import { useKV } from '../hooks/useKV'
|
| 7 |
+
|
| 8 |
+
interface CalendarProps {
|
| 9 |
+
onClose: () => void
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
interface Event {
|
| 13 |
+
id: string
|
| 14 |
+
date: string
|
| 15 |
+
title: string
|
| 16 |
+
type: 'holiday' | 'custom'
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
const defaultHolidays: Event[] = [
|
| 20 |
+
{ id: '1', date: '2025-01-01', title: 'New Year\'s Day', type: 'holiday' },
|
| 21 |
+
{ id: '2', date: '2025-02-14', title: 'Valentine\'s Day', type: 'holiday' },
|
| 22 |
+
{ id: '3', date: '2025-03-17', title: 'St. Patrick\'s Day', type: 'holiday' },
|
| 23 |
+
{ id: '4', date: '2025-04-20', title: 'Easter Sunday', type: 'holiday' },
|
| 24 |
+
{ id: '5', date: '2025-05-11', title: 'Mother\'s Day', type: 'holiday' },
|
| 25 |
+
{ id: '6', date: '2025-06-15', title: 'Father\'s Day', type: 'holiday' },
|
| 26 |
+
{ id: '7', date: '2025-07-04', title: 'Independence Day', type: 'holiday' },
|
| 27 |
+
{ id: '8', date: '2025-10-31', title: 'Halloween', type: 'holiday' },
|
| 28 |
+
{ id: '9', date: '2025-11-27', title: 'Thanksgiving', type: 'holiday' },
|
| 29 |
+
{ id: '10', date: '2025-12-25', title: 'Christmas Day', type: 'holiday' },
|
| 30 |
+
{ id: '11', date: '2025-12-31', title: 'New Year\'s Eve', type: 'holiday' },
|
| 31 |
+
]
|
| 32 |
+
|
| 33 |
+
export function Calendar({ onClose }: CalendarProps) {
|
| 34 |
+
const [currentDate, setCurrentDate] = useState(new Date())
|
| 35 |
+
const [windowPos, setWindowPos] = useState({ x: 100, y: 100 })
|
| 36 |
+
const [isDragging, setIsDragging] = useState(false)
|
| 37 |
+
const [dragStart, setDragStart] = useState({ x: 0, y: 0 })
|
| 38 |
+
const [events, setEvents] = useKV<Event[]>('calendar-events', defaultHolidays)
|
| 39 |
+
const [selectedDay, setSelectedDay] = useState<number | null>(null)
|
| 40 |
+
|
| 41 |
+
const handleMouseDown = (e: React.MouseEvent) => {
|
| 42 |
+
if ((e.target as HTMLElement).closest('.window-controls')) return
|
| 43 |
+
setIsDragging(true)
|
| 44 |
+
setDragStart({ x: e.clientX - windowPos.x, y: e.clientY - windowPos.y })
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
const handleMouseMove = (e: MouseEvent) => {
|
| 48 |
+
if (isDragging) {
|
| 49 |
+
setWindowPos({
|
| 50 |
+
x: e.clientX - dragStart.x,
|
| 51 |
+
y: e.clientY - dragStart.y,
|
| 52 |
+
})
|
| 53 |
+
}
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
const handleMouseUp = () => {
|
| 57 |
+
setIsDragging(false)
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
React.useEffect(() => {
|
| 61 |
+
if (isDragging) {
|
| 62 |
+
window.addEventListener('mousemove', handleMouseMove)
|
| 63 |
+
window.addEventListener('mouseup', handleMouseUp)
|
| 64 |
+
return () => {
|
| 65 |
+
window.removeEventListener('mousemove', handleMouseMove)
|
| 66 |
+
window.removeEventListener('mouseup', handleMouseUp)
|
| 67 |
+
}
|
| 68 |
+
}
|
| 69 |
+
}, [isDragging, dragStart])
|
| 70 |
+
|
| 71 |
+
const monthNames = [
|
| 72 |
+
'January', 'February', 'March', 'April', 'May', 'June',
|
| 73 |
+
'July', 'August', 'September', 'October', 'November', 'December'
|
| 74 |
+
]
|
| 75 |
+
|
| 76 |
+
const daysOfWeek = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
|
| 77 |
+
|
| 78 |
+
const getDaysInMonth = (date: Date) => {
|
| 79 |
+
const year = date.getFullYear()
|
| 80 |
+
const month = date.getMonth()
|
| 81 |
+
const firstDay = new Date(year, month, 1)
|
| 82 |
+
const lastDay = new Date(year, month + 1, 0)
|
| 83 |
+
const daysInMonth = lastDay.getDate()
|
| 84 |
+
const startingDayOfWeek = firstDay.getDay()
|
| 85 |
+
|
| 86 |
+
const days: (number | null)[] = []
|
| 87 |
+
for (let i = 0; i < startingDayOfWeek; i++) {
|
| 88 |
+
days.push(null)
|
| 89 |
+
}
|
| 90 |
+
for (let i = 1; i <= daysInMonth; i++) {
|
| 91 |
+
days.push(i)
|
| 92 |
+
}
|
| 93 |
+
return days
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
const days = getDaysInMonth(currentDate)
|
| 97 |
+
const today = new Date()
|
| 98 |
+
const isToday = (day: number | null) => {
|
| 99 |
+
if (!day) return false
|
| 100 |
+
return (
|
| 101 |
+
day === today.getDate() &&
|
| 102 |
+
currentDate.getMonth() === today.getMonth() &&
|
| 103 |
+
currentDate.getFullYear() === today.getFullYear()
|
| 104 |
+
)
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
const getEventsForDay = (day: number | null) => {
|
| 108 |
+
if (!day || !events) return []
|
| 109 |
+
const dateStr = `${currentDate.getFullYear()}-${String(currentDate.getMonth() + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`
|
| 110 |
+
return events.filter(event => event.date === dateStr)
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
const hasEvent = (day: number | null) => {
|
| 114 |
+
return getEventsForDay(day).length > 0
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
const previousMonth = () => {
|
| 118 |
+
setCurrentDate(new Date(currentDate.getFullYear(), currentDate.getMonth() - 1))
|
| 119 |
+
setSelectedDay(null)
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
const nextMonth = () => {
|
| 123 |
+
setCurrentDate(new Date(currentDate.getFullYear(), currentDate.getMonth() + 1))
|
| 124 |
+
setSelectedDay(null)
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
const handleDayClick = (day: number | null) => {
|
| 128 |
+
if (day) {
|
| 129 |
+
setSelectedDay(day)
|
| 130 |
+
}
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
const selectedDayEvents = selectedDay ? getEventsForDay(selectedDay) : []
|
| 134 |
+
|
| 135 |
+
return (
|
| 136 |
+
<motion.div
|
| 137 |
+
style={{ left: windowPos.x, top: windowPos.y }}
|
| 138 |
+
className="fixed w-[420px] bg-white rounded-lg shadow-2xl overflow-hidden flex flex-col z-30 select-none"
|
| 139 |
+
>
|
| 140 |
+
<div
|
| 141 |
+
onMouseDown={handleMouseDown}
|
| 142 |
+
className="h-11 bg-gradient-to-b from-[#f6f5f4] to-[#edebe9] border-b border-[#d0d0d0] flex items-center justify-between px-3 cursor-move"
|
| 143 |
+
>
|
| 144 |
+
<div className="flex items-center gap-2 flex-1">
|
| 145 |
+
<div className="flex items-center gap-1 window-controls">
|
| 146 |
+
<button
|
| 147 |
+
onClick={(e) => {
|
| 148 |
+
e.stopPropagation()
|
| 149 |
+
onClose()
|
| 150 |
+
}}
|
| 151 |
+
className="w-5 h-5 rounded-full bg-[#E95420] hover:bg-[#d14818] flex items-center justify-center group transition-colors"
|
| 152 |
+
>
|
| 153 |
+
<X size={12} weight="bold" className="text-white opacity-0 group-hover:opacity-100 transition-opacity" />
|
| 154 |
+
</button>
|
| 155 |
+
<button className="w-5 h-5 rounded-full bg-[#ddd] hover:bg-[#ccc] flex items-center justify-center group transition-colors">
|
| 156 |
+
<Minus size={12} weight="bold" className="text-[#666] opacity-0 group-hover:opacity-100 transition-opacity" />
|
| 157 |
+
</button>
|
| 158 |
+
<button className="w-5 h-5 rounded-full bg-[#ddd] hover:bg-[#ccc] flex items-center justify-center group transition-colors">
|
| 159 |
+
<Square size={10} weight="bold" className="text-[#666] opacity-0 group-hover:opacity-100 transition-opacity" />
|
| 160 |
+
</button>
|
| 161 |
+
</div>
|
| 162 |
+
<span className="text-sm font-medium text-[#2c2c2c] ml-2">Calendar</span>
|
| 163 |
+
</div>
|
| 164 |
+
</div>
|
| 165 |
+
|
| 166 |
+
<div className="p-6">
|
| 167 |
+
<div className="flex items-center justify-between mb-4">
|
| 168 |
+
<button
|
| 169 |
+
onClick={previousMonth}
|
| 170 |
+
className="p-2 hover:bg-[#f0f0f0] rounded"
|
| 171 |
+
>
|
| 172 |
+
<CaretLeft size={20} weight="bold" />
|
| 173 |
+
</button>
|
| 174 |
+
<div className="text-base font-semibold text-[#2c2c2c]">
|
| 175 |
+
{monthNames[currentDate.getMonth()]} {currentDate.getFullYear()}
|
| 176 |
+
</div>
|
| 177 |
+
<button
|
| 178 |
+
onClick={nextMonth}
|
| 179 |
+
className="p-2 hover:bg-[#f0f0f0] rounded"
|
| 180 |
+
>
|
| 181 |
+
<CaretRight size={20} weight="bold" />
|
| 182 |
+
</button>
|
| 183 |
+
</div>
|
| 184 |
+
|
| 185 |
+
<div className="grid grid-cols-7 gap-1 mb-2">
|
| 186 |
+
{daysOfWeek.map((day) => (
|
| 187 |
+
<div
|
| 188 |
+
key={day}
|
| 189 |
+
className="text-center text-xs font-medium text-[#666] py-1"
|
| 190 |
+
>
|
| 191 |
+
{day}
|
| 192 |
+
</div>
|
| 193 |
+
))}
|
| 194 |
+
</div>
|
| 195 |
+
|
| 196 |
+
<div className="grid grid-cols-7 gap-1">
|
| 197 |
+
{days.map((day, index) => (
|
| 198 |
+
day ? (
|
| 199 |
+
<button
|
| 200 |
+
key={index}
|
| 201 |
+
onClick={() => handleDayClick(day)}
|
| 202 |
+
className={`
|
| 203 |
+
aspect-square flex flex-col items-center justify-center text-sm rounded relative
|
| 204 |
+
hover:bg-[#f0f0f0]
|
| 205 |
+
${isToday(day) ? 'bg-[#E95420] text-white hover:bg-[#d14818] font-bold' : 'text-[#2c2c2c]'}
|
| 206 |
+
${selectedDay === day && !isToday(day) ? 'bg-[#f0f0f0] ring-2 ring-[#E95420]' : ''}
|
| 207 |
+
`}
|
| 208 |
+
>
|
| 209 |
+
<span>{day}</span>
|
| 210 |
+
{hasEvent(day) && (
|
| 211 |
+
<div className="absolute bottom-1 flex gap-0.5">
|
| 212 |
+
{getEventsForDay(day).slice(0, 3).map((event, i) => (
|
| 213 |
+
<div
|
| 214 |
+
key={i}
|
| 215 |
+
className={`w-1 h-1 rounded-full ${
|
| 216 |
+
event.type === 'holiday'
|
| 217 |
+
? isToday(day) ? 'bg-white' : 'bg-purple-500'
|
| 218 |
+
: isToday(day) ? 'bg-white' : 'bg-blue-500'
|
| 219 |
+
}`}
|
| 220 |
+
/>
|
| 221 |
+
))}
|
| 222 |
+
</div>
|
| 223 |
+
)}
|
| 224 |
+
</button>
|
| 225 |
+
) : (
|
| 226 |
+
<div
|
| 227 |
+
key={index}
|
| 228 |
+
className="aspect-square"
|
| 229 |
+
/>
|
| 230 |
+
)
|
| 231 |
+
))}
|
| 232 |
+
</div>
|
| 233 |
+
|
| 234 |
+
{selectedDayEvents.length > 0 && (
|
| 235 |
+
<div className="mt-4 pt-4 border-t border-[#e0e0e0]">
|
| 236 |
+
<div className="text-xs text-[#666] mb-2">
|
| 237 |
+
{monthNames[currentDate.getMonth()]} {selectedDay}, {currentDate.getFullYear()}
|
| 238 |
+
</div>
|
| 239 |
+
<div className="space-y-2 max-h-32 overflow-y-auto">
|
| 240 |
+
{selectedDayEvents.map((event) => (
|
| 241 |
+
<div
|
| 242 |
+
key={event.id}
|
| 243 |
+
className="flex items-start gap-2 p-2 bg-[#f9f9f9] rounded"
|
| 244 |
+
>
|
| 245 |
+
<div
|
| 246 |
+
className={`w-2 h-2 rounded-full mt-1.5 flex-shrink-0 ${
|
| 247 |
+
event.type === 'holiday' ? 'bg-purple-500' : 'bg-blue-500'
|
| 248 |
+
}`}
|
| 249 |
+
/>
|
| 250 |
+
<div className="flex-1">
|
| 251 |
+
<div className="text-sm font-medium text-[#2c2c2c]">
|
| 252 |
+
{event.title}
|
| 253 |
+
</div>
|
| 254 |
+
<div className="text-xs text-[#666]">
|
| 255 |
+
{event.type === 'holiday' ? 'Holiday' : 'Event'}
|
| 256 |
+
</div>
|
| 257 |
+
</div>
|
| 258 |
+
</div>
|
| 259 |
+
))}
|
| 260 |
+
</div>
|
| 261 |
+
</div>
|
| 262 |
+
)}
|
| 263 |
+
|
| 264 |
+
{!selectedDay && (
|
| 265 |
+
<div className="mt-4 pt-4 border-t border-[#e0e0e0]">
|
| 266 |
+
<div className="text-xs text-[#666] mb-2">Today</div>
|
| 267 |
+
<div className="text-sm font-medium text-[#2c2c2c]">
|
| 268 |
+
{today.toLocaleDateString('en-US', {
|
| 269 |
+
weekday: 'long',
|
| 270 |
+
year: 'numeric',
|
| 271 |
+
month: 'long',
|
| 272 |
+
day: 'numeric'
|
| 273 |
+
})}
|
| 274 |
+
</div>
|
| 275 |
+
{getEventsForDay(today.getDate()).length > 0 && (
|
| 276 |
+
<div className="mt-3 space-y-2">
|
| 277 |
+
{getEventsForDay(today.getDate()).map((event) => (
|
| 278 |
+
<div
|
| 279 |
+
key={event.id}
|
| 280 |
+
className="flex items-start gap-2 p-2 bg-[#fff3e6] rounded"
|
| 281 |
+
>
|
| 282 |
+
<div
|
| 283 |
+
className={`w-2 h-2 rounded-full mt-1.5 flex-shrink-0 ${
|
| 284 |
+
event.type === 'holiday' ? 'bg-purple-500' : 'bg-blue-500'
|
| 285 |
+
}`}
|
| 286 |
+
/>
|
| 287 |
+
<div className="flex-1">
|
| 288 |
+
<div className="text-sm font-medium text-[#2c2c2c]">
|
| 289 |
+
{event.title}
|
| 290 |
+
</div>
|
| 291 |
+
</div>
|
| 292 |
+
</div>
|
| 293 |
+
))}
|
| 294 |
+
</div>
|
| 295 |
+
)}
|
| 296 |
+
</div>
|
| 297 |
+
)}
|
| 298 |
+
</div>
|
| 299 |
+
</motion.div>
|
| 300 |
+
)
|
| 301 |
+
}
|
app/components/ClaudeIntegration.tsx
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import React, { useState } from 'react'
|
| 4 |
+
import { motion } from 'framer-motion'
|
| 5 |
+
import {
|
| 6 |
+
Upload,
|
| 7 |
+
Robot,
|
| 8 |
+
FileText,
|
| 9 |
+
CheckCircle,
|
| 10 |
+
Warning,
|
| 11 |
+
Info,
|
| 12 |
+
Copy,
|
| 13 |
+
Download
|
| 14 |
+
} from '@phosphor-icons/react'
|
| 15 |
+
|
| 16 |
+
interface ClaudeIntegrationProps {
|
| 17 |
+
file: File | null
|
| 18 |
+
onClose: () => void
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
export function ClaudeIntegration({ file, onClose }: ClaudeIntegrationProps) {
|
| 22 |
+
const [uploadMethod, setUploadMethod] = useState<'direct' | 'chunked' | 'reference'>('direct')
|
| 23 |
+
const [processing, setProcessing] = useState(false)
|
| 24 |
+
const [result, setResult] = useState<string>('')
|
| 25 |
+
|
| 26 |
+
const handleProcess = async () => {
|
| 27 |
+
if (!file) return
|
| 28 |
+
|
| 29 |
+
setProcessing(true)
|
| 30 |
+
|
| 31 |
+
try {
|
| 32 |
+
if (uploadMethod === 'direct') {
|
| 33 |
+
// Direct upload - best for small files < 1MB
|
| 34 |
+
const content = await file.text()
|
| 35 |
+
setResult(`File: ${file.name}\n\nContent:\n${content}`)
|
| 36 |
+
} else if (uploadMethod === 'chunked') {
|
| 37 |
+
// Chunked upload - for larger files, split into manageable chunks
|
| 38 |
+
const CHUNK_SIZE = 50000 // 50KB chunks
|
| 39 |
+
const content = await file.text()
|
| 40 |
+
const chunks = []
|
| 41 |
+
|
| 42 |
+
for (let i = 0; i < content.length; i += CHUNK_SIZE) {
|
| 43 |
+
chunks.push(content.slice(i, i + CHUNK_SIZE))
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
setResult(
|
| 47 |
+
`File: ${file.name}\n` +
|
| 48 |
+
`Total chunks: ${chunks.length}\n` +
|
| 49 |
+
`Chunk size: ${CHUNK_SIZE} characters\n\n` +
|
| 50 |
+
`Instructions for Claude:\n` +
|
| 51 |
+
`This file has been split into ${chunks.length} chunks.\n` +
|
| 52 |
+
`You can process each chunk sequentially.\n\n` +
|
| 53 |
+
`First chunk:\n${chunks[0].slice(0, 500)}...`
|
| 54 |
+
)
|
| 55 |
+
} else {
|
| 56 |
+
// Reference method - store file and provide reference
|
| 57 |
+
setResult(
|
| 58 |
+
`File Reference Created:\n` +
|
| 59 |
+
`Name: ${file.name}\n` +
|
| 60 |
+
`Size: ${formatFileSize(file.size)}\n` +
|
| 61 |
+
`Type: ${file.type}\n\n` +
|
| 62 |
+
`This file has been stored in the system.\n` +
|
| 63 |
+
`You can reference it by name in your conversation with Claude.\n` +
|
| 64 |
+
`Claude can access and analyze the file content when needed.`
|
| 65 |
+
)
|
| 66 |
+
}
|
| 67 |
+
} catch (error) {
|
| 68 |
+
console.error('Error processing file:', error)
|
| 69 |
+
setResult('Error processing file')
|
| 70 |
+
} finally {
|
| 71 |
+
setProcessing(false)
|
| 72 |
+
}
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
const formatFileSize = (bytes: number) => {
|
| 76 |
+
const units = ['B', 'KB', 'MB', 'GB']
|
| 77 |
+
let size = bytes
|
| 78 |
+
let unitIndex = 0
|
| 79 |
+
|
| 80 |
+
while (size >= 1024 && unitIndex < units.length - 1) {
|
| 81 |
+
size /= 1024
|
| 82 |
+
unitIndex++
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
return `${size.toFixed(2)} ${units[unitIndex]}`
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
const copyToClipboard = () => {
|
| 89 |
+
navigator.clipboard.writeText(result)
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
return (
|
| 93 |
+
<motion.div
|
| 94 |
+
initial={{ opacity: 0, y: 20 }}
|
| 95 |
+
animate={{ opacity: 1, y: 0 }}
|
| 96 |
+
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
|
| 97 |
+
onClick={onClose}
|
| 98 |
+
>
|
| 99 |
+
<motion.div
|
| 100 |
+
initial={{ scale: 0.9 }}
|
| 101 |
+
animate={{ scale: 1 }}
|
| 102 |
+
className="bg-[#2C2C2C]/95 backdrop-blur-md rounded-xl shadow-2xl max-w-2xl w-full max-h-[80vh] overflow-hidden border border-white/10"
|
| 103 |
+
onClick={(e) => e.stopPropagation()}
|
| 104 |
+
>
|
| 105 |
+
{/* Header */}
|
| 106 |
+
<div className="p-6 border-b border-white/10">
|
| 107 |
+
<div className="flex items-center gap-3">
|
| 108 |
+
<Robot size={24} className="text-blue-400" />
|
| 109 |
+
<h2 className="text-xl font-semibold text-white">Claude Integration</h2>
|
| 110 |
+
</div>
|
| 111 |
+
<p className="text-sm text-gray-400 mt-2">
|
| 112 |
+
Optimize file upload for Claude to preserve context
|
| 113 |
+
</p>
|
| 114 |
+
</div>
|
| 115 |
+
|
| 116 |
+
{/* Content */}
|
| 117 |
+
<div className="p-6 space-y-4 max-h-[60vh] overflow-y-auto">
|
| 118 |
+
{file && (
|
| 119 |
+
<div className="bg-white/5 rounded-lg p-4">
|
| 120 |
+
<div className="flex items-center gap-3">
|
| 121 |
+
<FileText size={24} className="text-gray-400" />
|
| 122 |
+
<div>
|
| 123 |
+
<p className="text-white font-medium">{file.name}</p>
|
| 124 |
+
<p className="text-sm text-gray-400">{formatFileSize(file.size)}</p>
|
| 125 |
+
</div>
|
| 126 |
+
</div>
|
| 127 |
+
</div>
|
| 128 |
+
)}
|
| 129 |
+
|
| 130 |
+
{/* Upload Methods */}
|
| 131 |
+
<div className="space-y-3">
|
| 132 |
+
<h3 className="text-sm font-medium text-gray-300 uppercase tracking-wider">
|
| 133 |
+
Upload Method
|
| 134 |
+
</h3>
|
| 135 |
+
|
| 136 |
+
<label className="flex items-start gap-3 p-3 rounded-lg border border-white/10 hover:border-white/20 cursor-pointer transition-colors">
|
| 137 |
+
<input
|
| 138 |
+
type="radio"
|
| 139 |
+
value="direct"
|
| 140 |
+
checked={uploadMethod === 'direct'}
|
| 141 |
+
onChange={(e) => setUploadMethod(e.target.value as any)}
|
| 142 |
+
className="mt-1"
|
| 143 |
+
/>
|
| 144 |
+
<div>
|
| 145 |
+
<p className="text-white font-medium">Direct Upload</p>
|
| 146 |
+
<p className="text-xs text-gray-400">
|
| 147 |
+
Best for small files (< 1MB). Uploads entire content at once.
|
| 148 |
+
</p>
|
| 149 |
+
</div>
|
| 150 |
+
</label>
|
| 151 |
+
|
| 152 |
+
<label className="flex items-start gap-3 p-3 rounded-lg border border-white/10 hover:border-white/20 cursor-pointer transition-colors">
|
| 153 |
+
<input
|
| 154 |
+
type="radio"
|
| 155 |
+
value="chunked"
|
| 156 |
+
checked={uploadMethod === 'chunked'}
|
| 157 |
+
onChange={(e) => setUploadMethod(e.target.value as any)}
|
| 158 |
+
className="mt-1"
|
| 159 |
+
/>
|
| 160 |
+
<div>
|
| 161 |
+
<p className="text-white font-medium">Chunked Upload</p>
|
| 162 |
+
<p className="text-xs text-gray-400">
|
| 163 |
+
For larger files. Splits content into manageable chunks.
|
| 164 |
+
</p>
|
| 165 |
+
</div>
|
| 166 |
+
</label>
|
| 167 |
+
|
| 168 |
+
<label className="flex items-start gap-3 p-3 rounded-lg border border-white/10 hover:border-white/20 cursor-pointer transition-colors">
|
| 169 |
+
<input
|
| 170 |
+
type="radio"
|
| 171 |
+
value="reference"
|
| 172 |
+
checked={uploadMethod === 'reference'}
|
| 173 |
+
onChange={(e) => setUploadMethod(e.target.value as any)}
|
| 174 |
+
className="mt-1"
|
| 175 |
+
/>
|
| 176 |
+
<div>
|
| 177 |
+
<p className="text-white font-medium">Reference Method</p>
|
| 178 |
+
<p className="text-xs text-gray-400">
|
| 179 |
+
Store file and provide reference. Claude accesses when needed.
|
| 180 |
+
</p>
|
| 181 |
+
</div>
|
| 182 |
+
</label>
|
| 183 |
+
</div>
|
| 184 |
+
|
| 185 |
+
{/* Info Box */}
|
| 186 |
+
<div className="bg-blue-500/10 border border-blue-500/20 rounded-lg p-4 flex gap-3">
|
| 187 |
+
<Info size={20} className="text-blue-400 flex-shrink-0 mt-0.5" />
|
| 188 |
+
<div className="text-sm text-gray-300">
|
| 189 |
+
<p className="font-medium mb-1">Context Preservation Tips:</p>
|
| 190 |
+
<ul className="space-y-1 text-xs text-gray-400">
|
| 191 |
+
<li>• Use direct upload for code files and short documents</li>
|
| 192 |
+
<li>• Use chunked upload for large documents or datasets</li>
|
| 193 |
+
<li>• Use reference method for binary files or archives</li>
|
| 194 |
+
</ul>
|
| 195 |
+
</div>
|
| 196 |
+
</div>
|
| 197 |
+
|
| 198 |
+
{/* Result */}
|
| 199 |
+
{result && (
|
| 200 |
+
<div className="bg-green-500/10 border border-green-500/20 rounded-lg p-4">
|
| 201 |
+
<div className="flex items-center justify-between mb-2">
|
| 202 |
+
<div className="flex items-center gap-2">
|
| 203 |
+
<CheckCircle size={20} className="text-green-400" />
|
| 204 |
+
<span className="text-sm font-medium text-green-400">Ready for Claude</span>
|
| 205 |
+
</div>
|
| 206 |
+
<button
|
| 207 |
+
onClick={copyToClipboard}
|
| 208 |
+
className="text-gray-400 hover:text-white transition-colors p-1"
|
| 209 |
+
title="Copy to clipboard"
|
| 210 |
+
>
|
| 211 |
+
<Copy size={18} />
|
| 212 |
+
</button>
|
| 213 |
+
</div>
|
| 214 |
+
<pre className="text-xs text-gray-300 whitespace-pre-wrap font-mono max-h-40 overflow-y-auto">
|
| 215 |
+
{result}
|
| 216 |
+
</pre>
|
| 217 |
+
</div>
|
| 218 |
+
)}
|
| 219 |
+
</div>
|
| 220 |
+
|
| 221 |
+
{/* Footer */}
|
| 222 |
+
<div className="p-6 pt-0 flex gap-3 justify-end">
|
| 223 |
+
<button
|
| 224 |
+
onClick={onClose}
|
| 225 |
+
className="px-4 py-2 text-gray-300 hover:text-white transition-colors"
|
| 226 |
+
>
|
| 227 |
+
Cancel
|
| 228 |
+
</button>
|
| 229 |
+
<button
|
| 230 |
+
onClick={handleProcess}
|
| 231 |
+
disabled={!file || processing}
|
| 232 |
+
className="px-4 py-2 bg-gradient-to-r from-blue-500 to-purple-500 text-white rounded-lg font-medium hover:from-blue-600 hover:to-purple-600 transition-all disabled:opacity-50"
|
| 233 |
+
>
|
| 234 |
+
{processing ? 'Processing...' : 'Process File'}
|
| 235 |
+
</button>
|
| 236 |
+
</div>
|
| 237 |
+
</motion.div>
|
| 238 |
+
</motion.div>
|
| 239 |
+
)
|
| 240 |
+
}
|
app/components/Clock.tsx
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import React, { useState, useEffect } from 'react'
|
| 4 |
+
import Window from './Window'
|
| 5 |
+
|
| 6 |
+
interface ClockProps {
|
| 7 |
+
onClose: () => void
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
export function Clock({ onClose }: ClockProps) {
|
| 11 |
+
const [time, setTime] = useState(new Date())
|
| 12 |
+
const [viewMode, setViewMode] = useState<'analog' | 'digital' | 'world'>('analog')
|
| 13 |
+
|
| 14 |
+
useEffect(() => {
|
| 15 |
+
const timer = setInterval(() => {
|
| 16 |
+
setTime(new Date())
|
| 17 |
+
}, 1000)
|
| 18 |
+
|
| 19 |
+
return () => clearInterval(timer)
|
| 20 |
+
}, [])
|
| 21 |
+
|
| 22 |
+
// Calculate rotation angles for clock hands
|
| 23 |
+
const seconds = time.getSeconds()
|
| 24 |
+
const minutes = time.getMinutes()
|
| 25 |
+
const hours = time.getHours()
|
| 26 |
+
|
| 27 |
+
const secondDegrees = (seconds / 60) * 360 - 90
|
| 28 |
+
const minuteDegrees = ((minutes / 60) * 360) + ((seconds / 60) * 6) - 90
|
| 29 |
+
const hourDegrees = ((hours % 12 / 12) * 360) + ((minutes / 60) * 30) - 90
|
| 30 |
+
|
| 31 |
+
const worldClocks = [
|
| 32 |
+
{ city: 'New York', offset: -5 },
|
| 33 |
+
{ city: 'London', offset: 0 },
|
| 34 |
+
{ city: 'Tokyo', offset: 9 },
|
| 35 |
+
{ city: 'Sydney', offset: 11 }
|
| 36 |
+
]
|
| 37 |
+
|
| 38 |
+
const getWorldTime = (offset: number) => {
|
| 39 |
+
const utc = time.getTime() + (time.getTimezoneOffset() * 60000)
|
| 40 |
+
const cityTime = new Date(utc + (3600000 * offset))
|
| 41 |
+
return cityTime.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
return (
|
| 45 |
+
<Window
|
| 46 |
+
id="clock"
|
| 47 |
+
title="Clock"
|
| 48 |
+
isOpen={true}
|
| 49 |
+
onClose={onClose}
|
| 50 |
+
width={380}
|
| 51 |
+
height={480}
|
| 52 |
+
x={window.innerWidth / 2 - 190}
|
| 53 |
+
y={window.innerHeight / 2 - 240}
|
| 54 |
+
darkMode={true}
|
| 55 |
+
className="clock-app-window"
|
| 56 |
+
>
|
| 57 |
+
<div className="flex flex-col h-full bg-gradient-to-b from-gray-900 via-gray-800 to-black">
|
| 58 |
+
{/* Tab Bar */}
|
| 59 |
+
<div className="flex border-b border-gray-700 bg-gray-900/50">
|
| 60 |
+
<button
|
| 61 |
+
onClick={() => setViewMode('analog')}
|
| 62 |
+
className={`flex-1 py-3 text-sm font-medium transition-all ${
|
| 63 |
+
viewMode === 'analog'
|
| 64 |
+
? 'text-orange-400 border-b-2 border-orange-400'
|
| 65 |
+
: 'text-gray-400 hover:text-white'
|
| 66 |
+
}`}
|
| 67 |
+
>
|
| 68 |
+
Analog
|
| 69 |
+
</button>
|
| 70 |
+
<button
|
| 71 |
+
onClick={() => setViewMode('digital')}
|
| 72 |
+
className={`flex-1 py-3 text-sm font-medium transition-all ${
|
| 73 |
+
viewMode === 'digital'
|
| 74 |
+
? 'text-orange-400 border-b-2 border-orange-400'
|
| 75 |
+
: 'text-gray-400 hover:text-white'
|
| 76 |
+
}`}
|
| 77 |
+
>
|
| 78 |
+
Digital
|
| 79 |
+
</button>
|
| 80 |
+
<button
|
| 81 |
+
onClick={() => setViewMode('world')}
|
| 82 |
+
className={`flex-1 py-3 text-sm font-medium transition-all ${
|
| 83 |
+
viewMode === 'world'
|
| 84 |
+
? 'text-orange-400 border-b-2 border-orange-400'
|
| 85 |
+
: 'text-gray-400 hover:text-white'
|
| 86 |
+
}`}
|
| 87 |
+
>
|
| 88 |
+
World Clock
|
| 89 |
+
</button>
|
| 90 |
+
</div>
|
| 91 |
+
|
| 92 |
+
{/* Clock Display */}
|
| 93 |
+
<div className="flex-1 flex items-center justify-center p-6">
|
| 94 |
+
{viewMode === 'analog' && (
|
| 95 |
+
<div className="flex flex-col items-center">
|
| 96 |
+
<div className="relative w-64 h-64">
|
| 97 |
+
{/* Clock Face */}
|
| 98 |
+
<div className="absolute inset-0 rounded-full bg-gradient-to-br from-gray-800 to-black shadow-inner border-4 border-gray-700">
|
| 99 |
+
{/* Hour Markers */}
|
| 100 |
+
{[...Array(12)].map((_, i) => (
|
| 101 |
+
<div key={i}>
|
| 102 |
+
<div
|
| 103 |
+
className="absolute bg-white"
|
| 104 |
+
style={{
|
| 105 |
+
width: i % 3 === 0 ? '3px' : '1px',
|
| 106 |
+
height: i % 3 === 0 ? '12px' : '8px',
|
| 107 |
+
top: '10px',
|
| 108 |
+
left: '50%',
|
| 109 |
+
transform: `translateX(-50%) rotate(${i * 30}deg)`,
|
| 110 |
+
transformOrigin: '50% 118px'
|
| 111 |
+
}}
|
| 112 |
+
/>
|
| 113 |
+
</div>
|
| 114 |
+
))}
|
| 115 |
+
|
| 116 |
+
{/* Clock Hands */}
|
| 117 |
+
<div
|
| 118 |
+
className="absolute top-1/2 left-1/2 w-2 h-20 bg-white rounded-full origin-bottom shadow-lg"
|
| 119 |
+
style={{
|
| 120 |
+
transform: `translate(-50%, -100%) rotate(${hourDegrees}deg)`
|
| 121 |
+
}}
|
| 122 |
+
/>
|
| 123 |
+
<div
|
| 124 |
+
className="absolute top-1/2 left-1/2 w-1.5 h-28 bg-gray-300 rounded-full origin-bottom shadow-lg"
|
| 125 |
+
style={{
|
| 126 |
+
transform: `translate(-50%, -100%) rotate(${minuteDegrees}deg)`
|
| 127 |
+
}}
|
| 128 |
+
/>
|
| 129 |
+
<div
|
| 130 |
+
className="absolute top-1/2 left-1/2 w-0.5 h-32 bg-orange-500 rounded-full origin-bottom"
|
| 131 |
+
style={{
|
| 132 |
+
transform: `translate(-50%, -100%) rotate(${secondDegrees}deg)`
|
| 133 |
+
}}
|
| 134 |
+
/>
|
| 135 |
+
|
| 136 |
+
{/* Center Dot */}
|
| 137 |
+
<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" />
|
| 138 |
+
</div>
|
| 139 |
+
</div>
|
| 140 |
+
|
| 141 |
+
{/* Digital Time Below Analog Clock */}
|
| 142 |
+
<div className="mt-6 text-center">
|
| 143 |
+
<div className="text-2xl font-mono text-gray-300">
|
| 144 |
+
{time.toLocaleTimeString()}
|
| 145 |
+
</div>
|
| 146 |
+
<div className="text-sm text-gray-500 mt-2">
|
| 147 |
+
{time.toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}
|
| 148 |
+
</div>
|
| 149 |
+
</div>
|
| 150 |
+
</div>
|
| 151 |
+
)}
|
| 152 |
+
|
| 153 |
+
{viewMode === 'digital' && (
|
| 154 |
+
<div className="text-center">
|
| 155 |
+
<div className="text-7xl font-mono font-bold text-transparent bg-clip-text bg-gradient-to-r from-orange-400 to-orange-600">
|
| 156 |
+
{time.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
|
| 157 |
+
</div>
|
| 158 |
+
<div className="mt-8 text-2xl text-gray-400">
|
| 159 |
+
{time.toLocaleDateString('en-US', { weekday: 'long' })}
|
| 160 |
+
</div>
|
| 161 |
+
<div className="text-lg text-gray-500">
|
| 162 |
+
{time.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}
|
| 163 |
+
</div>
|
| 164 |
+
</div>
|
| 165 |
+
)}
|
| 166 |
+
|
| 167 |
+
{viewMode === 'world' && (
|
| 168 |
+
<div className="w-full max-w-sm">
|
| 169 |
+
<div className="space-y-4">
|
| 170 |
+
{worldClocks.map((clock) => (
|
| 171 |
+
<div key={clock.city} className="bg-gray-800 rounded-lg p-4 flex justify-between items-center">
|
| 172 |
+
<div>
|
| 173 |
+
<div className="text-white font-medium">{clock.city}</div>
|
| 174 |
+
<div className="text-xs text-gray-400">UTC{clock.offset >= 0 ? '+' : ''}{clock.offset}</div>
|
| 175 |
+
</div>
|
| 176 |
+
<div className="text-2xl font-mono text-orange-400">
|
| 177 |
+
{getWorldTime(clock.offset)}
|
| 178 |
+
</div>
|
| 179 |
+
</div>
|
| 180 |
+
))}
|
| 181 |
+
</div>
|
| 182 |
+
</div>
|
| 183 |
+
)}
|
| 184 |
+
</div>
|
| 185 |
+
</div>
|
| 186 |
+
</Window>
|
| 187 |
+
)
|
| 188 |
+
}
|
app/components/CodeExecutor.tsx
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import React, { useState } from 'react'
|
| 4 |
+
import Window from './Window'
|
| 5 |
+
import Editor from '@monaco-editor/react'
|
| 6 |
+
import {
|
| 7 |
+
Play,
|
| 8 |
+
Code,
|
| 9 |
+
FileText,
|
| 10 |
+
Download,
|
| 11 |
+
Copy,
|
| 12 |
+
CheckCircle,
|
| 13 |
+
Warning,
|
| 14 |
+
CloudArrowUp
|
| 15 |
+
} from '@phosphor-icons/react'
|
| 16 |
+
|
| 17 |
+
interface CodeExecutorProps {
|
| 18 |
+
onClose: () => void
|
| 19 |
+
initialCode?: string
|
| 20 |
+
language?: string
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
export function CodeExecutor({ onClose, initialCode = '', language = 'python' }: CodeExecutorProps) {
|
| 24 |
+
const [code, setCode] = useState(initialCode || `# Reuben OS Code Executor
|
| 25 |
+
# Write your Python code here and click Run to execute
|
| 26 |
+
|
| 27 |
+
import matplotlib.pyplot as plt
|
| 28 |
+
import numpy as np
|
| 29 |
+
|
| 30 |
+
# Create sample data
|
| 31 |
+
x = np.linspace(0, 10, 100)
|
| 32 |
+
y = np.sin(x)
|
| 33 |
+
|
| 34 |
+
# Create plot
|
| 35 |
+
plt.figure(figsize=(10, 6))
|
| 36 |
+
plt.plot(x, y, 'b-', linewidth=2, label='sin(x)')
|
| 37 |
+
plt.grid(True, alpha=0.3)
|
| 38 |
+
plt.xlabel('X axis')
|
| 39 |
+
plt.ylabel('Y axis')
|
| 40 |
+
plt.title('Sample Plot in Reuben OS')
|
| 41 |
+
plt.legend()
|
| 42 |
+
|
| 43 |
+
# This will automatically save and display the plot
|
| 44 |
+
plt.show()
|
| 45 |
+
|
| 46 |
+
print("Plot generated successfully!")
|
| 47 |
+
print(f"X range: {x[0]:.2f} to {x[-1]:.2f}")
|
| 48 |
+
print(f"Y range: {y.min():.2f} to {y.max():.2f}")`)
|
| 49 |
+
|
| 50 |
+
const [output, setOutput] = useState('')
|
| 51 |
+
const [isRunning, setIsRunning] = useState(false)
|
| 52 |
+
const [error, setError] = useState('')
|
| 53 |
+
const [plotPath, setPlotPath] = useState('')
|
| 54 |
+
const [selectedLanguage, setSelectedLanguage] = useState(language)
|
| 55 |
+
|
| 56 |
+
const executeCode = async () => {
|
| 57 |
+
setIsRunning(true)
|
| 58 |
+
setOutput('')
|
| 59 |
+
setError('')
|
| 60 |
+
setPlotPath('')
|
| 61 |
+
|
| 62 |
+
try {
|
| 63 |
+
// Save code to file system first
|
| 64 |
+
const sessionId = `exec_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
|
| 65 |
+
const saveResponse = await fetch('/api/code/save', {
|
| 66 |
+
method: 'POST',
|
| 67 |
+
headers: { 'Content-Type': 'application/json' },
|
| 68 |
+
body: JSON.stringify({
|
| 69 |
+
sessionId,
|
| 70 |
+
code: [{
|
| 71 |
+
name: `script.${selectedLanguage === 'python' ? 'py' : selectedLanguage === 'javascript' ? 'js' : 'html'}`,
|
| 72 |
+
language: selectedLanguage,
|
| 73 |
+
content: code
|
| 74 |
+
}],
|
| 75 |
+
timestamp: Date.now()
|
| 76 |
+
})
|
| 77 |
+
})
|
| 78 |
+
|
| 79 |
+
if (!saveResponse.ok) {
|
| 80 |
+
throw new Error('Failed to save code')
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
// Execute based on language
|
| 84 |
+
if (selectedLanguage === 'python') {
|
| 85 |
+
// For Python, we need MCP to execute it
|
| 86 |
+
setOutput('Python code saved! Use MCP tools to execute:\n')
|
| 87 |
+
setOutput(prev => prev + `\nSession ID: ${sessionId}\n`)
|
| 88 |
+
setOutput(prev => prev + `\nFile saved to: data/vscode_sessions/${sessionId}/script.py\n`)
|
| 89 |
+
setOutput(prev => prev + '\n📝 Note: To execute Python code, use the MCP execute_python_code tool from Claude Desktop.')
|
| 90 |
+
|
| 91 |
+
// Show sample MCP command
|
| 92 |
+
setOutput(prev => prev + '\n\n💡 MCP Command Example:\n')
|
| 93 |
+
setOutput(prev => prev + 'execute_python_code(code=<your_code>, save_output=true)\n')
|
| 94 |
+
|
| 95 |
+
// If it's matplotlib code, suggest using execute_matplotlib_code
|
| 96 |
+
if (code.includes('matplotlib') || code.includes('plt.')) {
|
| 97 |
+
setOutput(prev => prev + '\n🎨 For matplotlib plots, use:\n')
|
| 98 |
+
setOutput(prev => prev + 'execute_matplotlib_code(code=<your_code>, output_format="png")')
|
| 99 |
+
}
|
| 100 |
+
} else if (selectedLanguage === 'html' || selectedLanguage === 'javascript') {
|
| 101 |
+
// For HTML/JS, we can execute in browser
|
| 102 |
+
executeWebCode()
|
| 103 |
+
}
|
| 104 |
+
} catch (err) {
|
| 105 |
+
setError(err instanceof Error ? err.message : 'Unknown error occurred')
|
| 106 |
+
} finally {
|
| 107 |
+
setIsRunning(false)
|
| 108 |
+
}
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
const executeWebCode = () => {
|
| 112 |
+
if (selectedLanguage === 'html') {
|
| 113 |
+
// Create iframe for HTML preview
|
| 114 |
+
const iframe = document.createElement('iframe')
|
| 115 |
+
iframe.style.width = '100%'
|
| 116 |
+
iframe.style.height = '100%'
|
| 117 |
+
iframe.style.border = 'none'
|
| 118 |
+
iframe.srcdoc = code
|
| 119 |
+
|
| 120 |
+
const outputElement = document.getElementById('code-output')
|
| 121 |
+
if (outputElement) {
|
| 122 |
+
outputElement.innerHTML = ''
|
| 123 |
+
outputElement.appendChild(iframe)
|
| 124 |
+
}
|
| 125 |
+
setOutput('HTML rendered in preview pane')
|
| 126 |
+
} else if (selectedLanguage === 'javascript') {
|
| 127 |
+
// Execute JavaScript in sandboxed environment
|
| 128 |
+
try {
|
| 129 |
+
// Capture console.log outputs
|
| 130 |
+
const logs: string[] = []
|
| 131 |
+
const originalLog = console.log
|
| 132 |
+
console.log = (...args) => {
|
| 133 |
+
logs.push(args.map(arg => String(arg)).join(' '))
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
// Execute the code
|
| 137 |
+
const func = new Function(code)
|
| 138 |
+
const result = func()
|
| 139 |
+
|
| 140 |
+
// Restore console.log
|
| 141 |
+
console.log = originalLog
|
| 142 |
+
|
| 143 |
+
// Display output
|
| 144 |
+
let outputText = logs.join('\n')
|
| 145 |
+
if (result !== undefined) {
|
| 146 |
+
outputText += `\n\nReturn value: ${JSON.stringify(result, null, 2)}`
|
| 147 |
+
}
|
| 148 |
+
setOutput(outputText || 'Code executed successfully (no output)')
|
| 149 |
+
} catch (err) {
|
| 150 |
+
setError(err instanceof Error ? err.message : 'JavaScript execution error')
|
| 151 |
+
}
|
| 152 |
+
}
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
const copyOutput = () => {
|
| 156 |
+
navigator.clipboard.writeText(output || error)
|
| 157 |
+
|
| 158 |
+
// Show copied feedback
|
| 159 |
+
const copiedDiv = document.createElement('div')
|
| 160 |
+
copiedDiv.className = 'fixed bottom-4 right-4 bg-blue-600 text-white px-4 py-2 rounded-lg shadow-lg z-[200] flex items-center gap-2'
|
| 161 |
+
copiedDiv.innerHTML = '<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><path d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z"></path><path fill-rule="evenodd" d="M4 5a2 2 0 012-2 1 1 0 000 2H6a2 2 0 100 4h8a2 2 0 100-4 1 1 0 100-2 2 2 0 00-2 2H8a2 2 0 00-2-2z" clip-rule="evenodd"></path></svg> Copied!'
|
| 162 |
+
document.body.appendChild(copiedDiv)
|
| 163 |
+
|
| 164 |
+
setTimeout(() => {
|
| 165 |
+
copiedDiv.remove()
|
| 166 |
+
}, 2000)
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
return (
|
| 170 |
+
<Window
|
| 171 |
+
id="code-executor"
|
| 172 |
+
title="Code Executor - Reuben OS"
|
| 173 |
+
isOpen={true}
|
| 174 |
+
onClose={onClose}
|
| 175 |
+
width={1200}
|
| 176 |
+
height={700}
|
| 177 |
+
x={100}
|
| 178 |
+
y={50}
|
| 179 |
+
darkMode={true}
|
| 180 |
+
>
|
| 181 |
+
<div className="flex flex-col h-full bg-[#1e1e1e]">
|
| 182 |
+
{/* Header */}
|
| 183 |
+
<div className="flex items-center justify-between bg-[#2d2d2d] px-4 py-2 border-b border-[#3e3e3e]">
|
| 184 |
+
<div className="flex items-center gap-4">
|
| 185 |
+
<Code size={20} weight="bold" className="text-blue-400" />
|
| 186 |
+
<select
|
| 187 |
+
value={selectedLanguage}
|
| 188 |
+
onChange={(e) => setSelectedLanguage(e.target.value)}
|
| 189 |
+
className="bg-[#1e1e1e] text-white px-3 py-1 rounded border border-[#3e3e3e] text-sm"
|
| 190 |
+
>
|
| 191 |
+
<option value="python">Python</option>
|
| 192 |
+
<option value="javascript">JavaScript</option>
|
| 193 |
+
<option value="html">HTML</option>
|
| 194 |
+
</select>
|
| 195 |
+
</div>
|
| 196 |
+
|
| 197 |
+
<div className="flex items-center gap-2">
|
| 198 |
+
<button
|
| 199 |
+
onClick={executeCode}
|
| 200 |
+
disabled={isRunning}
|
| 201 |
+
className="px-4 py-1 bg-green-600 hover:bg-green-700 disabled:bg-gray-600 text-white rounded text-sm flex items-center gap-2"
|
| 202 |
+
>
|
| 203 |
+
<Play size={16} weight="fill" />
|
| 204 |
+
{isRunning ? 'Running...' : 'Run'}
|
| 205 |
+
</button>
|
| 206 |
+
|
| 207 |
+
<button
|
| 208 |
+
onClick={() => setCode('')}
|
| 209 |
+
className="px-3 py-1 bg-gray-600 hover:bg-gray-700 text-white rounded text-sm"
|
| 210 |
+
>
|
| 211 |
+
Clear
|
| 212 |
+
</button>
|
| 213 |
+
</div>
|
| 214 |
+
</div>
|
| 215 |
+
|
| 216 |
+
{/* Main Content */}
|
| 217 |
+
<div className="flex flex-1 overflow-hidden">
|
| 218 |
+
{/* Code Editor */}
|
| 219 |
+
<div className="w-1/2 border-r border-[#3e3e3e]">
|
| 220 |
+
<Editor
|
| 221 |
+
theme="vs-dark"
|
| 222 |
+
language={selectedLanguage}
|
| 223 |
+
value={code}
|
| 224 |
+
onChange={(value) => setCode(value || '')}
|
| 225 |
+
options={{
|
| 226 |
+
minimap: { enabled: false },
|
| 227 |
+
fontSize: 14,
|
| 228 |
+
lineNumbers: 'on',
|
| 229 |
+
roundedSelection: false,
|
| 230 |
+
scrollBeyondLastLine: false,
|
| 231 |
+
automaticLayout: true,
|
| 232 |
+
wordWrap: 'on'
|
| 233 |
+
}}
|
| 234 |
+
/>
|
| 235 |
+
</div>
|
| 236 |
+
|
| 237 |
+
{/* Output Panel */}
|
| 238 |
+
<div className="w-1/2 flex flex-col bg-[#1e1e1e]">
|
| 239 |
+
<div className="flex items-center justify-between bg-[#252526] px-4 py-2 border-b border-[#3e3e3e]">
|
| 240 |
+
<span className="text-sm text-gray-300 flex items-center gap-2">
|
| 241 |
+
<FileText size={16} />
|
| 242 |
+
Output
|
| 243 |
+
</span>
|
| 244 |
+
<button
|
| 245 |
+
onClick={copyOutput}
|
| 246 |
+
className="text-gray-400 hover:text-white transition-colors"
|
| 247 |
+
disabled={!output && !error}
|
| 248 |
+
>
|
| 249 |
+
<Copy size={16} />
|
| 250 |
+
</button>
|
| 251 |
+
</div>
|
| 252 |
+
|
| 253 |
+
<div id="code-output" className="flex-1 p-4 font-mono text-sm overflow-auto">
|
| 254 |
+
{error ? (
|
| 255 |
+
<div className="text-red-400">
|
| 256 |
+
<div className="flex items-center gap-2 mb-2">
|
| 257 |
+
<Warning size={16} />
|
| 258 |
+
Error:
|
| 259 |
+
</div>
|
| 260 |
+
<pre className="whitespace-pre-wrap">{error}</pre>
|
| 261 |
+
</div>
|
| 262 |
+
) : output ? (
|
| 263 |
+
<div className="text-green-400">
|
| 264 |
+
<pre className="whitespace-pre-wrap">{output}</pre>
|
| 265 |
+
{plotPath && (
|
| 266 |
+
<div className="mt-4">
|
| 267 |
+
<p className="text-blue-400 mb-2">Plot saved to:</p>
|
| 268 |
+
<code className="bg-[#2d2d2d] px-2 py-1 rounded">{plotPath}</code>
|
| 269 |
+
</div>
|
| 270 |
+
)}
|
| 271 |
+
</div>
|
| 272 |
+
) : (
|
| 273 |
+
<div className="text-gray-500 flex items-center justify-center h-full">
|
| 274 |
+
<div className="text-center">
|
| 275 |
+
<Code size={48} className="mx-auto mb-4 opacity-20" />
|
| 276 |
+
<p>Write code and click Run to see output</p>
|
| 277 |
+
<p className="text-xs mt-2 text-gray-600">
|
| 278 |
+
Python execution requires MCP tools from Claude Desktop
|
| 279 |
+
</p>
|
| 280 |
+
</div>
|
| 281 |
+
</div>
|
| 282 |
+
)}
|
| 283 |
+
</div>
|
| 284 |
+
|
| 285 |
+
{/* Info Panel */}
|
| 286 |
+
<div className="bg-[#252526] p-3 border-t border-[#3e3e3e]">
|
| 287 |
+
<div className="text-xs text-gray-400">
|
| 288 |
+
{selectedLanguage === 'python' ? (
|
| 289 |
+
<div className="flex items-center gap-2">
|
| 290 |
+
<CloudArrowUp size={14} />
|
| 291 |
+
<span>Python code is saved to disk. Use MCP tools to execute.</span>
|
| 292 |
+
</div>
|
| 293 |
+
) : (
|
| 294 |
+
<div className="flex items-center gap-2">
|
| 295 |
+
<CheckCircle size={14} />
|
| 296 |
+
<span>HTML/JS executes directly in browser sandbox.</span>
|
| 297 |
+
</div>
|
| 298 |
+
)}
|
| 299 |
+
</div>
|
| 300 |
+
</div>
|
| 301 |
+
</div>
|
| 302 |
+
</div>
|
| 303 |
+
</div>
|
| 304 |
+
</Window>
|
| 305 |
+
)
|
| 306 |
+
}
|
app/components/CodePlayground.tsx
ADDED
|
@@ -0,0 +1,668 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import React, { useState, useRef, useEffect } from 'react'
|
| 4 |
+
import Window from './Window'
|
| 5 |
+
import Editor from '@monaco-editor/react'
|
| 6 |
+
import {
|
| 7 |
+
Code,
|
| 8 |
+
Play,
|
| 9 |
+
FileHtml,
|
| 10 |
+
FileCss,
|
| 11 |
+
FileJs,
|
| 12 |
+
FilePy,
|
| 13 |
+
FileCode,
|
| 14 |
+
Download,
|
| 15 |
+
Upload,
|
| 16 |
+
FloppyDisk,
|
| 17 |
+
Eye,
|
| 18 |
+
EyeSlash,
|
| 19 |
+
ArrowsOutSimple,
|
| 20 |
+
ArrowsInSimple,
|
| 21 |
+
Plus,
|
| 22 |
+
X,
|
| 23 |
+
Globe,
|
| 24 |
+
Lock,
|
| 25 |
+
Users,
|
| 26 |
+
Folder,
|
| 27 |
+
Terminal as TerminalIcon,
|
| 28 |
+
Lightning
|
| 29 |
+
} from '@phosphor-icons/react'
|
| 30 |
+
|
| 31 |
+
interface CodePlaygroundProps {
|
| 32 |
+
onClose: () => void
|
| 33 |
+
userSession: string
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
interface Tab {
|
| 37 |
+
id: string
|
| 38 |
+
name: string
|
| 39 |
+
language: string
|
| 40 |
+
content: string
|
| 41 |
+
isPublic?: boolean
|
| 42 |
+
type: 'html' | 'css' | 'javascript' | 'python' | 'react' | 'flutter' | 'typescript'
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
interface ExecutionResult {
|
| 46 |
+
output: string
|
| 47 |
+
error?: string
|
| 48 |
+
timestamp: number
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
export function CodePlayground({ onClose, userSession }: CodePlaygroundProps) {
|
| 52 |
+
const [activeTab, setActiveTab] = useState<string>('main')
|
| 53 |
+
const [showPreview, setShowPreview] = useState(true)
|
| 54 |
+
const [showConsole, setShowConsole] = useState(true)
|
| 55 |
+
const [isFullscreen, setIsFullscreen] = useState(false)
|
| 56 |
+
const [isSaving, setIsSaving] = useState(false)
|
| 57 |
+
const [isExecuting, setIsExecuting] = useState(false)
|
| 58 |
+
const [executionResults, setExecutionResults] = useState<ExecutionResult[]>([])
|
| 59 |
+
const [publicFiles, setPublicFiles] = useState<Tab[]>([])
|
| 60 |
+
const previewRef = useRef<HTMLIFrameElement>(null)
|
| 61 |
+
const [draggedTab, setDraggedTab] = useState<string | null>(null)
|
| 62 |
+
const [dragOverTab, setDragOverTab] = useState<string | null>(null)
|
| 63 |
+
|
| 64 |
+
// Enhanced tab icons with more languages
|
| 65 |
+
const getTabIcon = (type: string) => {
|
| 66 |
+
const icons: Record<string, JSX.Element> = {
|
| 67 |
+
'html': <FileHtml size={16} weight="fill" className="text-orange-500" />,
|
| 68 |
+
'css': <FileCss size={16} weight="fill" className="text-blue-500" />,
|
| 69 |
+
'javascript': <FileJs size={16} weight="fill" className="text-yellow-500" />,
|
| 70 |
+
'typescript': <FileCode size={16} weight="fill" className="text-blue-600" />,
|
| 71 |
+
'python': <FilePy size={16} weight="fill" className="text-green-500" />,
|
| 72 |
+
'react': <FileJs size={16} weight="fill" className="text-cyan-500" />,
|
| 73 |
+
'flutter': <FileCode size={16} weight="fill" className="text-blue-400" />
|
| 74 |
+
}
|
| 75 |
+
return icons[type] || <Code size={16} weight="fill" className="text-gray-500" />
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
const [tabs, setTabs] = useState<Tab[]>([
|
| 79 |
+
{
|
| 80 |
+
id: 'main',
|
| 81 |
+
name: 'main.py',
|
| 82 |
+
language: 'python',
|
| 83 |
+
type: 'python',
|
| 84 |
+
content: `# Welcome to WebOS Code Playground!
|
| 85 |
+
# You can write and execute Python code here
|
| 86 |
+
|
| 87 |
+
def greet(name):
|
| 88 |
+
return f"Hello, {name}! Welcome to WebOS"
|
| 89 |
+
|
| 90 |
+
# Example usage
|
| 91 |
+
result = greet("Developer")
|
| 92 |
+
print(result)
|
| 93 |
+
|
| 94 |
+
# Your code here
|
| 95 |
+
for i in range(5):
|
| 96 |
+
print(f"Count: {i}")
|
| 97 |
+
`,
|
| 98 |
+
isPublic: false
|
| 99 |
+
}
|
| 100 |
+
])
|
| 101 |
+
|
| 102 |
+
// Load public files from backend
|
| 103 |
+
useEffect(() => {
|
| 104 |
+
loadPublicFiles()
|
| 105 |
+
}, [])
|
| 106 |
+
|
| 107 |
+
const loadPublicFiles = async () => {
|
| 108 |
+
try {
|
| 109 |
+
const response = await fetch('/api/code/public')
|
| 110 |
+
if (response.ok) {
|
| 111 |
+
const files = await response.json()
|
| 112 |
+
setPublicFiles(files)
|
| 113 |
+
}
|
| 114 |
+
} catch (error) {
|
| 115 |
+
console.error('Failed to load public files:', error)
|
| 116 |
+
}
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
// Tab drag and drop handlers
|
| 120 |
+
const handleDragStart = (e: React.DragEvent, tabId: string) => {
|
| 121 |
+
setDraggedTab(tabId)
|
| 122 |
+
e.dataTransfer.effectAllowed = 'move'
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
const handleDragOver = (e: React.DragEvent, tabId: string) => {
|
| 126 |
+
e.preventDefault()
|
| 127 |
+
if (draggedTab && draggedTab !== tabId) {
|
| 128 |
+
setDragOverTab(tabId)
|
| 129 |
+
}
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
const handleDragLeave = () => {
|
| 133 |
+
setDragOverTab(null)
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
const handleDrop = (e: React.DragEvent, targetTabId: string) => {
|
| 137 |
+
e.preventDefault()
|
| 138 |
+
|
| 139 |
+
if (draggedTab && draggedTab !== targetTabId) {
|
| 140 |
+
const draggedIndex = tabs.findIndex(t => t.id === draggedTab)
|
| 141 |
+
const targetIndex = tabs.findIndex(t => t.id === targetTabId)
|
| 142 |
+
|
| 143 |
+
if (draggedIndex !== -1 && targetIndex !== -1) {
|
| 144 |
+
const newTabs = [...tabs]
|
| 145 |
+
const [draggedTabObj] = newTabs.splice(draggedIndex, 1)
|
| 146 |
+
newTabs.splice(targetIndex, 0, draggedTabObj)
|
| 147 |
+
setTabs(newTabs)
|
| 148 |
+
}
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
setDraggedTab(null)
|
| 152 |
+
setDragOverTab(null)
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
const handleDragEnd = () => {
|
| 156 |
+
setDraggedTab(null)
|
| 157 |
+
setDragOverTab(null)
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
// Create new tab with language selection
|
| 161 |
+
const createNewTab = (language: Tab['type']) => {
|
| 162 |
+
const templates: Record<Tab['type'], { name: string, content: string, lang: string }> = {
|
| 163 |
+
'python': {
|
| 164 |
+
name: 'script.py',
|
| 165 |
+
lang: 'python',
|
| 166 |
+
content: `# Python Script
|
| 167 |
+
def main():
|
| 168 |
+
print("Hello from Python!")
|
| 169 |
+
|
| 170 |
+
if __name__ == "__main__":
|
| 171 |
+
main()
|
| 172 |
+
`
|
| 173 |
+
},
|
| 174 |
+
'javascript': {
|
| 175 |
+
name: 'script.js',
|
| 176 |
+
lang: 'javascript',
|
| 177 |
+
content: `// JavaScript Code
|
| 178 |
+
console.log("Hello from JavaScript!");
|
| 179 |
+
|
| 180 |
+
function calculate(a, b) {
|
| 181 |
+
return a + b;
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
console.log("Result:", calculate(5, 3));
|
| 185 |
+
`
|
| 186 |
+
},
|
| 187 |
+
'typescript': {
|
| 188 |
+
name: 'script.ts',
|
| 189 |
+
lang: 'typescript',
|
| 190 |
+
content: `// TypeScript Code
|
| 191 |
+
interface User {
|
| 192 |
+
name: string;
|
| 193 |
+
age: number;
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
function greetUser(user: User): void {
|
| 197 |
+
console.log(\`Hello, \${user.name}! You are \${user.age} years old.\`);
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
greetUser({ name: "Alice", age: 25 });
|
| 201 |
+
`
|
| 202 |
+
},
|
| 203 |
+
'react': {
|
| 204 |
+
name: 'App.jsx',
|
| 205 |
+
lang: 'javascript',
|
| 206 |
+
content: `// React Component
|
| 207 |
+
import React, { useState } from 'react';
|
| 208 |
+
|
| 209 |
+
function App() {
|
| 210 |
+
const [count, setCount] = useState(0);
|
| 211 |
+
|
| 212 |
+
return (
|
| 213 |
+
<div>
|
| 214 |
+
<h1>React Counter</h1>
|
| 215 |
+
<p>Count: {count}</p>
|
| 216 |
+
<button onClick={() => setCount(count + 1)}>
|
| 217 |
+
Increment
|
| 218 |
+
</button>
|
| 219 |
+
</div>
|
| 220 |
+
);
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
export default App;
|
| 224 |
+
`
|
| 225 |
+
},
|
| 226 |
+
'flutter': {
|
| 227 |
+
name: 'main.dart',
|
| 228 |
+
lang: 'dart',
|
| 229 |
+
content: `// Flutter Widget
|
| 230 |
+
import 'package:flutter/material.dart';
|
| 231 |
+
|
| 232 |
+
class MyWidget extends StatefulWidget {
|
| 233 |
+
@override
|
| 234 |
+
_MyWidgetState createState() => _MyWidgetState();
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
class _MyWidgetState extends State<MyWidget> {
|
| 238 |
+
int counter = 0;
|
| 239 |
+
|
| 240 |
+
@override
|
| 241 |
+
Widget build(BuildContext context) {
|
| 242 |
+
return Scaffold(
|
| 243 |
+
appBar: AppBar(title: Text('Flutter App')),
|
| 244 |
+
body: Center(
|
| 245 |
+
child: Column(
|
| 246 |
+
mainAxisAlignment: MainAxisAlignment.center,
|
| 247 |
+
children: [
|
| 248 |
+
Text('Counter: $counter'),
|
| 249 |
+
ElevatedButton(
|
| 250 |
+
onPressed: () => setState(() => counter++),
|
| 251 |
+
child: Text('Increment'),
|
| 252 |
+
),
|
| 253 |
+
],
|
| 254 |
+
),
|
| 255 |
+
),
|
| 256 |
+
);
|
| 257 |
+
}
|
| 258 |
+
}
|
| 259 |
+
`
|
| 260 |
+
},
|
| 261 |
+
'html': {
|
| 262 |
+
name: 'index.html',
|
| 263 |
+
lang: 'html',
|
| 264 |
+
content: `<!DOCTYPE html>
|
| 265 |
+
<html>
|
| 266 |
+
<head>
|
| 267 |
+
<title>HTML Page</title>
|
| 268 |
+
</head>
|
| 269 |
+
<body>
|
| 270 |
+
<h1>Hello WebOS!</h1>
|
| 271 |
+
</body>
|
| 272 |
+
</html>`
|
| 273 |
+
},
|
| 274 |
+
'css': {
|
| 275 |
+
name: 'style.css',
|
| 276 |
+
lang: 'css',
|
| 277 |
+
content: `/* CSS Styles */
|
| 278 |
+
body {
|
| 279 |
+
font-family: Arial, sans-serif;
|
| 280 |
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
| 281 |
+
}
|
| 282 |
+
`
|
| 283 |
+
}
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
const template = templates[language]
|
| 287 |
+
const newTab: Tab = {
|
| 288 |
+
id: `tab_${Date.now()}`,
|
| 289 |
+
name: template.name,
|
| 290 |
+
language: template.lang,
|
| 291 |
+
type: language,
|
| 292 |
+
content: template.content,
|
| 293 |
+
isPublic: false
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
setTabs(prev => [...prev, newTab])
|
| 297 |
+
setActiveTab(newTab.id)
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
// Execute code based on language
|
| 301 |
+
const executeCode = async () => {
|
| 302 |
+
setIsExecuting(true)
|
| 303 |
+
const activeTabData = tabs.find(t => t.id === activeTab)
|
| 304 |
+
|
| 305 |
+
if (!activeTabData) {
|
| 306 |
+
setIsExecuting(false)
|
| 307 |
+
return
|
| 308 |
+
}
|
| 309 |
+
|
| 310 |
+
try {
|
| 311 |
+
const response = await fetch('/api/code/execute', {
|
| 312 |
+
method: 'POST',
|
| 313 |
+
headers: { 'Content-Type': 'application/json' },
|
| 314 |
+
body: JSON.stringify({
|
| 315 |
+
sessionId: userSession,
|
| 316 |
+
language: activeTabData.type,
|
| 317 |
+
code: activeTabData.content,
|
| 318 |
+
timestamp: Date.now()
|
| 319 |
+
})
|
| 320 |
+
})
|
| 321 |
+
|
| 322 |
+
const result = await response.json()
|
| 323 |
+
|
| 324 |
+
setExecutionResults(prev => [...prev, {
|
| 325 |
+
output: result.output || 'No output',
|
| 326 |
+
error: result.error,
|
| 327 |
+
timestamp: Date.now()
|
| 328 |
+
}])
|
| 329 |
+
} catch (error) {
|
| 330 |
+
setExecutionResults(prev => [...prev, {
|
| 331 |
+
output: '',
|
| 332 |
+
error: `Execution failed: ${error}`,
|
| 333 |
+
timestamp: Date.now()
|
| 334 |
+
}])
|
| 335 |
+
}
|
| 336 |
+
|
| 337 |
+
setIsExecuting(false)
|
| 338 |
+
}
|
| 339 |
+
|
| 340 |
+
// Save to public folder
|
| 341 |
+
const saveToPublic = async () => {
|
| 342 |
+
const activeTabData = tabs.find(t => t.id === activeTab)
|
| 343 |
+
if (!activeTabData) return
|
| 344 |
+
|
| 345 |
+
setIsSaving(true)
|
| 346 |
+
try {
|
| 347 |
+
const response = await fetch('/api/code/public/save', {
|
| 348 |
+
method: 'POST',
|
| 349 |
+
headers: { 'Content-Type': 'application/json' },
|
| 350 |
+
body: JSON.stringify({
|
| 351 |
+
sessionId: userSession,
|
| 352 |
+
file: {
|
| 353 |
+
...activeTabData,
|
| 354 |
+
isPublic: true,
|
| 355 |
+
author: userSession
|
| 356 |
+
}
|
| 357 |
+
})
|
| 358 |
+
})
|
| 359 |
+
|
| 360 |
+
if (response.ok) {
|
| 361 |
+
setTabs(prev => prev.map(tab =>
|
| 362 |
+
tab.id === activeTab ? { ...tab, isPublic: true } : tab
|
| 363 |
+
))
|
| 364 |
+
await loadPublicFiles()
|
| 365 |
+
}
|
| 366 |
+
} catch (error) {
|
| 367 |
+
console.error('Failed to save to public:', error)
|
| 368 |
+
}
|
| 369 |
+
setIsSaving(false)
|
| 370 |
+
}
|
| 371 |
+
|
| 372 |
+
const handleEditorChange = (value: string | undefined) => {
|
| 373 |
+
if (!value) return
|
| 374 |
+
|
| 375 |
+
setTabs(prev => prev.map(tab =>
|
| 376 |
+
tab.id === activeTab ? { ...tab, content: value } : tab
|
| 377 |
+
))
|
| 378 |
+
}
|
| 379 |
+
|
| 380 |
+
const activeTabContent = tabs.find(t => t.id === activeTab)
|
| 381 |
+
|
| 382 |
+
// Close tab
|
| 383 |
+
const closeTab = (tabId: string) => {
|
| 384 |
+
if (tabs.length === 1) return // Keep at least one tab
|
| 385 |
+
|
| 386 |
+
setTabs(prev => prev.filter(t => t.id !== tabId))
|
| 387 |
+
if (activeTab === tabId) {
|
| 388 |
+
setActiveTab(tabs[0].id)
|
| 389 |
+
}
|
| 390 |
+
}
|
| 391 |
+
|
| 392 |
+
// Load file from public
|
| 393 |
+
const loadPublicFile = (file: Tab) => {
|
| 394 |
+
const newTab: Tab = {
|
| 395 |
+
...file,
|
| 396 |
+
id: `tab_${Date.now()}`,
|
| 397 |
+
isPublic: false
|
| 398 |
+
}
|
| 399 |
+
setTabs(prev => [...prev, newTab])
|
| 400 |
+
setActiveTab(newTab.id)
|
| 401 |
+
}
|
| 402 |
+
|
| 403 |
+
return (
|
| 404 |
+
<Window
|
| 405 |
+
id="code-playground"
|
| 406 |
+
title={`Code Playground - Session: ${userSession.substring(0, 8)}...`}
|
| 407 |
+
isOpen={true}
|
| 408 |
+
onClose={onClose}
|
| 409 |
+
width={isFullscreen ? window.innerWidth : 1400}
|
| 410 |
+
height={isFullscreen ? window.innerHeight - 32 : 850}
|
| 411 |
+
x={isFullscreen ? 0 : 50}
|
| 412 |
+
y={isFullscreen ? 32 : 30}
|
| 413 |
+
darkMode={true}
|
| 414 |
+
className="code-playground-window"
|
| 415 |
+
>
|
| 416 |
+
<div className="flex flex-col h-full bg-[#1e1e1e]">
|
| 417 |
+
{/* Toolbar */}
|
| 418 |
+
<div className="flex items-center justify-between bg-[#2d2d2d] px-4 py-2 border-b border-[#3e3e3e]">
|
| 419 |
+
<div className="flex items-center gap-3">
|
| 420 |
+
<Lightning size={20} weight="bold" className="text-yellow-400" />
|
| 421 |
+
<span className="text-gray-300 text-sm font-medium">Multi-Language Playground</span>
|
| 422 |
+
<div className="flex items-center gap-1 px-2 py-1 bg-[#1e1e1e] rounded text-xs text-gray-400">
|
| 423 |
+
<Users size={14} />
|
| 424 |
+
<span>{userSession.substring(0, 8)}</span>
|
| 425 |
+
</div>
|
| 426 |
+
</div>
|
| 427 |
+
|
| 428 |
+
<div className="flex items-center gap-2">
|
| 429 |
+
<button
|
| 430 |
+
onClick={executeCode}
|
| 431 |
+
disabled={isExecuting}
|
| 432 |
+
className="px-3 py-1 bg-green-600 hover:bg-green-700 text-white rounded text-sm flex items-center gap-2 disabled:opacity-50"
|
| 433 |
+
>
|
| 434 |
+
<Play size={16} weight="fill" />
|
| 435 |
+
{isExecuting ? 'Running...' : 'Run'}
|
| 436 |
+
</button>
|
| 437 |
+
|
| 438 |
+
<button
|
| 439 |
+
onClick={saveToPublic}
|
| 440 |
+
disabled={isSaving}
|
| 441 |
+
className="px-3 py-1 bg-blue-600 hover:bg-blue-700 text-white rounded text-sm flex items-center gap-2 disabled:opacity-50"
|
| 442 |
+
>
|
| 443 |
+
<Globe size={16} />
|
| 444 |
+
{isSaving ? 'Publishing...' : 'Publish'}
|
| 445 |
+
</button>
|
| 446 |
+
|
| 447 |
+
<button
|
| 448 |
+
onClick={() => setShowConsole(!showConsole)}
|
| 449 |
+
className="px-3 py-1 bg-gray-600 hover:bg-gray-700 text-white rounded text-sm flex items-center gap-2"
|
| 450 |
+
>
|
| 451 |
+
<TerminalIcon size={16} />
|
| 452 |
+
Console
|
| 453 |
+
</button>
|
| 454 |
+
|
| 455 |
+
<button
|
| 456 |
+
onClick={() => setShowPreview(!showPreview)}
|
| 457 |
+
className="px-3 py-1 bg-gray-600 hover:bg-gray-700 text-white rounded text-sm flex items-center gap-2"
|
| 458 |
+
>
|
| 459 |
+
{showPreview ? <EyeSlash size={16} /> : <Eye size={16} />}
|
| 460 |
+
Preview
|
| 461 |
+
</button>
|
| 462 |
+
|
| 463 |
+
<button
|
| 464 |
+
onClick={() => setIsFullscreen(!isFullscreen)}
|
| 465 |
+
className="px-3 py-1 bg-gray-600 hover:bg-gray-700 text-white rounded text-sm"
|
| 466 |
+
>
|
| 467 |
+
{isFullscreen ? <ArrowsInSimple size={16} /> : <ArrowsOutSimple size={16} />}
|
| 468 |
+
</button>
|
| 469 |
+
</div>
|
| 470 |
+
</div>
|
| 471 |
+
|
| 472 |
+
{/* Main Content Area */}
|
| 473 |
+
<div className="flex flex-1 overflow-hidden">
|
| 474 |
+
{/* Sidebar - Public Files */}
|
| 475 |
+
<div className="w-64 bg-[#252526] border-r border-[#3e3e3e] flex flex-col">
|
| 476 |
+
<div className="p-3 border-b border-[#3e3e3e]">
|
| 477 |
+
<div className="flex items-center gap-2 text-gray-300 text-sm font-medium">
|
| 478 |
+
<Folder size={16} />
|
| 479 |
+
Public Files
|
| 480 |
+
</div>
|
| 481 |
+
</div>
|
| 482 |
+
<div className="flex-1 overflow-y-auto p-2">
|
| 483 |
+
{publicFiles.map(file => (
|
| 484 |
+
<button
|
| 485 |
+
key={file.id}
|
| 486 |
+
onClick={() => loadPublicFile(file)}
|
| 487 |
+
className="w-full text-left px-2 py-1.5 text-xs text-gray-400 hover:bg-[#2d2d2d] rounded flex items-center gap-2"
|
| 488 |
+
>
|
| 489 |
+
{getTabIcon(file.type)}
|
| 490 |
+
<span className="truncate">{file.name}</span>
|
| 491 |
+
</button>
|
| 492 |
+
))}
|
| 493 |
+
</div>
|
| 494 |
+
</div>
|
| 495 |
+
|
| 496 |
+
{/* Editor Section */}
|
| 497 |
+
<div className={`flex flex-col ${showPreview ? 'w-1/2' : 'flex-1'}`}>
|
| 498 |
+
{/* Tabs with drag and drop */}
|
| 499 |
+
<div className="flex bg-[#252526] border-b border-[#3e3e3e] items-center">
|
| 500 |
+
<div className="flex-1 flex overflow-x-auto">
|
| 501 |
+
{tabs.map(tab => (
|
| 502 |
+
<div
|
| 503 |
+
key={tab.id}
|
| 504 |
+
draggable
|
| 505 |
+
onDragStart={(e) => handleDragStart(e, tab.id)}
|
| 506 |
+
onDragOver={(e) => handleDragOver(e, tab.id)}
|
| 507 |
+
onDragLeave={handleDragLeave}
|
| 508 |
+
onDrop={(e) => handleDrop(e, tab.id)}
|
| 509 |
+
onDragEnd={handleDragEnd}
|
| 510 |
+
className={`
|
| 511 |
+
flex items-center gap-2 px-3 py-2 text-sm border-r border-[#3e3e3e]
|
| 512 |
+
cursor-move transition-all select-none
|
| 513 |
+
${activeTab === tab.id ? 'bg-[#1e1e1e] text-white' : 'text-gray-400 hover:text-white hover:bg-[#2d2d2d]'}
|
| 514 |
+
${dragOverTab === tab.id ? 'border-l-2 border-l-blue-500' : ''}
|
| 515 |
+
`}
|
| 516 |
+
onClick={() => setActiveTab(tab.id)}
|
| 517 |
+
>
|
| 518 |
+
{getTabIcon(tab.type)}
|
| 519 |
+
<span>{tab.name}</span>
|
| 520 |
+
{tab.isPublic && (
|
| 521 |
+
<Globe size={12} className="text-green-400" />
|
| 522 |
+
)}
|
| 523 |
+
{tabs.length > 1 && (
|
| 524 |
+
<button
|
| 525 |
+
onClick={(e) => {
|
| 526 |
+
e.stopPropagation()
|
| 527 |
+
closeTab(tab.id)
|
| 528 |
+
}}
|
| 529 |
+
className="ml-1 hover:bg-[#3e3e3e] rounded p-0.5"
|
| 530 |
+
>
|
| 531 |
+
<X size={12} />
|
| 532 |
+
</button>
|
| 533 |
+
)}
|
| 534 |
+
</div>
|
| 535 |
+
))}
|
| 536 |
+
</div>
|
| 537 |
+
|
| 538 |
+
{/* Add new tab dropdown */}
|
| 539 |
+
<div className="relative group">
|
| 540 |
+
<button className="px-3 py-2 text-gray-400 hover:text-white hover:bg-[#2d2d2d]">
|
| 541 |
+
<Plus size={16} />
|
| 542 |
+
</button>
|
| 543 |
+
<div className="absolute right-0 top-full mt-1 bg-[#252526] border border-[#3e3e3e] rounded shadow-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all z-10">
|
| 544 |
+
<button
|
| 545 |
+
onClick={() => createNewTab('python')}
|
| 546 |
+
className="block w-full px-3 py-2 text-sm text-gray-300 hover:bg-[#2d2d2d] text-left"
|
| 547 |
+
>
|
| 548 |
+
Python
|
| 549 |
+
</button>
|
| 550 |
+
<button
|
| 551 |
+
onClick={() => createNewTab('javascript')}
|
| 552 |
+
className="block w-full px-3 py-2 text-sm text-gray-300 hover:bg-[#2d2d2d] text-left"
|
| 553 |
+
>
|
| 554 |
+
JavaScript
|
| 555 |
+
</button>
|
| 556 |
+
<button
|
| 557 |
+
onClick={() => createNewTab('typescript')}
|
| 558 |
+
className="block w-full px-3 py-2 text-sm text-gray-300 hover:bg-[#2d2d2d] text-left"
|
| 559 |
+
>
|
| 560 |
+
TypeScript
|
| 561 |
+
</button>
|
| 562 |
+
<button
|
| 563 |
+
onClick={() => createNewTab('react')}
|
| 564 |
+
className="block w-full px-3 py-2 text-sm text-gray-300 hover:bg-[#2d2d2d] text-left"
|
| 565 |
+
>
|
| 566 |
+
React
|
| 567 |
+
</button>
|
| 568 |
+
<button
|
| 569 |
+
onClick={() => createNewTab('flutter')}
|
| 570 |
+
className="block w-full px-3 py-2 text-sm text-gray-300 hover:bg-[#2d2d2d] text-left"
|
| 571 |
+
>
|
| 572 |
+
Flutter/Dart
|
| 573 |
+
</button>
|
| 574 |
+
<button
|
| 575 |
+
onClick={() => createNewTab('html')}
|
| 576 |
+
className="block w-full px-3 py-2 text-sm text-gray-300 hover:bg-[#2d2d2d] text-left"
|
| 577 |
+
>
|
| 578 |
+
HTML
|
| 579 |
+
</button>
|
| 580 |
+
<button
|
| 581 |
+
onClick={() => createNewTab('css')}
|
| 582 |
+
className="block w-full px-3 py-2 text-sm text-gray-300 hover:bg-[#2d2d2d] text-left"
|
| 583 |
+
>
|
| 584 |
+
CSS
|
| 585 |
+
</button>
|
| 586 |
+
</div>
|
| 587 |
+
</div>
|
| 588 |
+
</div>
|
| 589 |
+
|
| 590 |
+
{/* Monaco Editor */}
|
| 591 |
+
<div className={`flex-1 ${showConsole ? 'h-3/5' : ''}`}>
|
| 592 |
+
<Editor
|
| 593 |
+
height="100%"
|
| 594 |
+
language={activeTabContent?.language || 'python'}
|
| 595 |
+
value={activeTabContent?.content || ''}
|
| 596 |
+
onChange={handleEditorChange}
|
| 597 |
+
theme="vs-dark"
|
| 598 |
+
options={{
|
| 599 |
+
minimap: { enabled: true },
|
| 600 |
+
fontSize: 14,
|
| 601 |
+
wordWrap: 'on',
|
| 602 |
+
automaticLayout: true,
|
| 603 |
+
scrollBeyondLastLine: false,
|
| 604 |
+
tabSize: 4
|
| 605 |
+
}}
|
| 606 |
+
/>
|
| 607 |
+
</div>
|
| 608 |
+
|
| 609 |
+
{/* Console Output */}
|
| 610 |
+
{showConsole && (
|
| 611 |
+
<div className="h-2/5 bg-[#1e1e1e] border-t border-[#3e3e3e] flex flex-col">
|
| 612 |
+
<div className="flex items-center justify-between px-3 py-1 bg-[#252526] border-b border-[#3e3e3e]">
|
| 613 |
+
<span className="text-xs text-gray-400">Console Output</span>
|
| 614 |
+
<button
|
| 615 |
+
onClick={() => setExecutionResults([])}
|
| 616 |
+
className="text-xs text-gray-400 hover:text-white"
|
| 617 |
+
>
|
| 618 |
+
Clear
|
| 619 |
+
</button>
|
| 620 |
+
</div>
|
| 621 |
+
<div className="flex-1 overflow-y-auto p-3 font-mono text-xs">
|
| 622 |
+
{executionResults.map((result, index) => (
|
| 623 |
+
<div key={index} className="mb-2">
|
| 624 |
+
<div className="text-gray-500 text-xs mb-1">
|
| 625 |
+
[{new Date(result.timestamp).toLocaleTimeString()}]
|
| 626 |
+
</div>
|
| 627 |
+
{result.error ? (
|
| 628 |
+
<div className="text-red-400">{result.error}</div>
|
| 629 |
+
) : (
|
| 630 |
+
<div className="text-green-400 whitespace-pre-wrap">{result.output}</div>
|
| 631 |
+
)}
|
| 632 |
+
</div>
|
| 633 |
+
))}
|
| 634 |
+
</div>
|
| 635 |
+
</div>
|
| 636 |
+
)}
|
| 637 |
+
</div>
|
| 638 |
+
|
| 639 |
+
{/* Preview Section (for HTML/CSS/JS) */}
|
| 640 |
+
{showPreview && (activeTabContent?.type === 'html' || activeTabContent?.type === 'css' || activeTabContent?.type === 'javascript') && (
|
| 641 |
+
<div className="flex-1 flex flex-col bg-white">
|
| 642 |
+
<div className="bg-gray-100 px-4 py-2 border-b border-gray-300 flex items-center justify-between">
|
| 643 |
+
<span className="text-sm font-medium">Live Preview</span>
|
| 644 |
+
<button
|
| 645 |
+
onClick={() => {
|
| 646 |
+
if (previewRef.current) {
|
| 647 |
+
previewRef.current.src = previewRef.current.src
|
| 648 |
+
}
|
| 649 |
+
}}
|
| 650 |
+
className="p-1 hover:bg-gray-200 rounded"
|
| 651 |
+
>
|
| 652 |
+
<Play size={16} className="text-gray-600" />
|
| 653 |
+
</button>
|
| 654 |
+
</div>
|
| 655 |
+
<iframe
|
| 656 |
+
ref={previewRef}
|
| 657 |
+
className="flex-1 w-full bg-white"
|
| 658 |
+
sandbox="allow-scripts"
|
| 659 |
+
srcDoc={activeTabContent?.content}
|
| 660 |
+
title="Code Preview"
|
| 661 |
+
/>
|
| 662 |
+
</div>
|
| 663 |
+
)}
|
| 664 |
+
</div>
|
| 665 |
+
</div>
|
| 666 |
+
</Window>
|
| 667 |
+
)
|
| 668 |
+
}
|
app/components/ContextMenu.tsx
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import React from 'react'
|
| 4 |
+
import { motion, AnimatePresence } from 'framer-motion'
|
| 5 |
+
import {
|
| 6 |
+
FolderPlus,
|
| 7 |
+
Info,
|
| 8 |
+
Image,
|
| 9 |
+
Broom,
|
| 10 |
+
ArrowsClockwise,
|
| 11 |
+
Desktop
|
| 12 |
+
} from '@phosphor-icons/react'
|
| 13 |
+
|
| 14 |
+
interface ContextMenuProps {
|
| 15 |
+
isOpen: boolean
|
| 16 |
+
position: { x: number; y: number }
|
| 17 |
+
onClose: () => void
|
| 18 |
+
onAction: (action: string) => void
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
export function ContextMenu({ isOpen, position, onClose, onAction }: ContextMenuProps) {
|
| 22 |
+
const menuItems = [
|
| 23 |
+
{ id: 'new-folder', label: 'New Folder', icon: FolderPlus },
|
| 24 |
+
{ id: 'get-info', label: 'Get Info', icon: Info },
|
| 25 |
+
{ type: 'separator' },
|
| 26 |
+
{ id: 'change-wallpaper', label: 'Change Wallpaper', icon: Image },
|
| 27 |
+
{ id: 'clean-up', label: 'Clean Up', icon: Broom },
|
| 28 |
+
{ id: 'refresh', label: 'Refresh', icon: ArrowsClockwise },
|
| 29 |
+
{ type: 'separator' },
|
| 30 |
+
{ id: 'display-settings', label: 'Display Settings', icon: Desktop },
|
| 31 |
+
]
|
| 32 |
+
|
| 33 |
+
const handleAction = (actionId: string) => {
|
| 34 |
+
onAction(actionId)
|
| 35 |
+
onClose()
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
return (
|
| 39 |
+
<AnimatePresence>
|
| 40 |
+
{isOpen && (
|
| 41 |
+
<>
|
| 42 |
+
{/* Invisible backdrop to detect outside clicks */}
|
| 43 |
+
<div
|
| 44 |
+
className="fixed inset-0 z-[79]"
|
| 45 |
+
onClick={onClose}
|
| 46 |
+
/>
|
| 47 |
+
|
| 48 |
+
{/* Context Menu */}
|
| 49 |
+
<motion.div
|
| 50 |
+
initial={{ opacity: 0, scale: 0.95 }}
|
| 51 |
+
animate={{ opacity: 1, scale: 1 }}
|
| 52 |
+
exit={{ opacity: 0, scale: 0.95 }}
|
| 53 |
+
transition={{ duration: 0.1 }}
|
| 54 |
+
className="fixed glass rounded-lg shadow-xl py-1 z-[80] min-w-[200px]"
|
| 55 |
+
style={{
|
| 56 |
+
left: position.x,
|
| 57 |
+
top: position.y,
|
| 58 |
+
// Prevent menu from going off-screen
|
| 59 |
+
maxWidth: `calc(100vw - ${position.x}px - 20px)`,
|
| 60 |
+
maxHeight: `calc(100vh - ${position.y}px - 20px)`
|
| 61 |
+
}}
|
| 62 |
+
>
|
| 63 |
+
{menuItems.map((item, index) => {
|
| 64 |
+
if (item.type === 'separator') {
|
| 65 |
+
return (
|
| 66 |
+
<div
|
| 67 |
+
key={`separator-${index}`}
|
| 68 |
+
className="h-px bg-gray-300/50 my-1 mx-2"
|
| 69 |
+
/>
|
| 70 |
+
)
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
const Icon = item.icon!
|
| 74 |
+
return (
|
| 75 |
+
<button
|
| 76 |
+
key={item.id}
|
| 77 |
+
onClick={() => handleAction(item.id!)}
|
| 78 |
+
className="w-full text-left px-4 py-1.5 hover:bg-blue-500 hover:text-white transition-colors flex items-center gap-3 text-sm"
|
| 79 |
+
>
|
| 80 |
+
<Icon size={16} weight="regular" />
|
| 81 |
+
<span>{item.label}</span>
|
| 82 |
+
</button>
|
| 83 |
+
)
|
| 84 |
+
})}
|
| 85 |
+
</motion.div>
|
| 86 |
+
</>
|
| 87 |
+
)}
|
| 88 |
+
</AnimatePresence>
|
| 89 |
+
)
|
| 90 |
+
}
|
app/components/Desktop.tsx
ADDED
|
@@ -0,0 +1,498 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import React, { useState, useEffect } from 'react'
|
| 4 |
+
import { Dock } from './Dock'
|
| 5 |
+
import { TopBar } from './TopBar'
|
| 6 |
+
import { FileManager } from './FileManager'
|
| 7 |
+
import { Calendar } from './Calendar'
|
| 8 |
+
import { DraggableDesktopIcon } from './DraggableDesktopIcon'
|
| 9 |
+
import { MatrixRain } from './MatrixRain'
|
| 10 |
+
import { HelpModal } from './HelpModal'
|
| 11 |
+
import { DesktopContextMenu } from './DesktopContextMenu'
|
| 12 |
+
import { BackgroundSelector } from './BackgroundSelector'
|
| 13 |
+
import WebBrowserApp from './WebBrowserApp'
|
| 14 |
+
import { GeminiChat } from './GeminiChat'
|
| 15 |
+
import { Clock } from './Clock'
|
| 16 |
+
import { Terminal } from './Terminal'
|
| 17 |
+
import { SpotlightSearch } from './SpotlightSearch'
|
| 18 |
+
import { ContextMenu } from './ContextMenu'
|
| 19 |
+
import { VSCodeEditor } from './VSCodeEditor'
|
| 20 |
+
import { CodePlayground } from './CodePlayground'
|
| 21 |
+
import { AboutModal } from './AboutModal'
|
| 22 |
+
import { CodeExecutor } from './CodeExecutor'
|
| 23 |
+
import { motion } from 'framer-motion'
|
| 24 |
+
|
| 25 |
+
export function Desktop() {
|
| 26 |
+
const [fileManagerOpen, setFileManagerOpen] = useState(true)
|
| 27 |
+
const [calendarOpen, setCalendarOpen] = useState(false)
|
| 28 |
+
const [clockOpen, setClockOpen] = useState(false)
|
| 29 |
+
const [browserOpen, setBrowserOpen] = useState(false)
|
| 30 |
+
const [geminiChatOpen, setGeminiChatOpen] = useState(false)
|
| 31 |
+
const [terminalOpen, setTerminalOpen] = useState(false)
|
| 32 |
+
const [vscodeOpen, setVscodeOpen] = useState(false)
|
| 33 |
+
const [codePlaygroundOpen, setCodePlaygroundOpen] = useState(false)
|
| 34 |
+
const [spotlightOpen, setSpotlightOpen] = useState(false)
|
| 35 |
+
const [contextMenuVisible, setContextMenuVisible] = useState(false)
|
| 36 |
+
const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 })
|
| 37 |
+
const [userSession, setUserSession] = useState<string>(`session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`)
|
| 38 |
+
const [currentPath, setCurrentPath] = useState('')
|
| 39 |
+
const [matrixActive, setMatrixActive] = useState(false)
|
| 40 |
+
const [helpModalOpen, setHelpModalOpen] = useState(false)
|
| 41 |
+
const [contextMenuOpen, setContextMenuOpen] = useState(false)
|
| 42 |
+
const [contextMenuPos, setContextMenuPos] = useState({ x: 0, y: 0 })
|
| 43 |
+
const [backgroundSelectorOpen, setBackgroundSelectorOpen] = useState(false)
|
| 44 |
+
const [currentBackground, setCurrentBackground] = useState('/background.webp')
|
| 45 |
+
const [aboutModalOpen, setAboutModalOpen] = useState(false)
|
| 46 |
+
const [codeExecutorOpen, setCodeExecutorOpen] = useState(false)
|
| 47 |
+
|
| 48 |
+
const openFileManager = (path: string) => {
|
| 49 |
+
setCurrentPath(path)
|
| 50 |
+
setFileManagerOpen(true)
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
const closeFileManager = () => {
|
| 54 |
+
setFileManagerOpen(false)
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
const openCalendar = () => {
|
| 58 |
+
setCalendarOpen(true)
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
const closeCalendar = () => {
|
| 62 |
+
setCalendarOpen(false)
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
const openClock = () => {
|
| 66 |
+
setClockOpen(true)
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
const closeClock = () => {
|
| 70 |
+
setClockOpen(false)
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
const openBrowser = () => {
|
| 74 |
+
setBrowserOpen(true)
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
const closeBrowser = () => {
|
| 78 |
+
setBrowserOpen(false)
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
const openGeminiChat = () => {
|
| 82 |
+
setGeminiChatOpen(true)
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
const closeGeminiChat = () => {
|
| 86 |
+
setGeminiChatOpen(false)
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
const openTerminal = () => {
|
| 90 |
+
setTerminalOpen(true)
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
const closeTerminal = () => {
|
| 94 |
+
setTerminalOpen(false)
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
const openVSCode = () => {
|
| 98 |
+
setVscodeOpen(true)
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
const closeVSCode = () => {
|
| 102 |
+
setVscodeOpen(false)
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
const openCodePlayground = () => {
|
| 106 |
+
setCodePlaygroundOpen(true)
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
const closeCodePlayground = () => {
|
| 110 |
+
setCodePlaygroundOpen(false)
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
const openCodeExecutor = () => {
|
| 114 |
+
setCodeExecutorOpen(true)
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
const closeCodeExecutor = () => {
|
| 118 |
+
setCodeExecutorOpen(false)
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
const handleOpenApp = (appId: string) => {
|
| 122 |
+
switch(appId) {
|
| 123 |
+
case 'files':
|
| 124 |
+
openFileManager('')
|
| 125 |
+
break
|
| 126 |
+
case 'calendar':
|
| 127 |
+
openCalendar()
|
| 128 |
+
break
|
| 129 |
+
case 'clock':
|
| 130 |
+
openClock()
|
| 131 |
+
break
|
| 132 |
+
case 'browser':
|
| 133 |
+
openBrowser()
|
| 134 |
+
break
|
| 135 |
+
case 'gemini':
|
| 136 |
+
openGeminiChat()
|
| 137 |
+
break
|
| 138 |
+
case 'terminal':
|
| 139 |
+
openTerminal()
|
| 140 |
+
break
|
| 141 |
+
case 'vscode':
|
| 142 |
+
openVSCode()
|
| 143 |
+
break
|
| 144 |
+
case 'playground':
|
| 145 |
+
openCodePlayground()
|
| 146 |
+
break
|
| 147 |
+
case 'executor':
|
| 148 |
+
openCodeExecutor()
|
| 149 |
+
break
|
| 150 |
+
}
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
const handleContextMenuAction = (action: string) => {
|
| 154 |
+
switch(action) {
|
| 155 |
+
case 'change-wallpaper':
|
| 156 |
+
setBackgroundSelectorOpen(true)
|
| 157 |
+
break
|
| 158 |
+
case 'new-folder':
|
| 159 |
+
// Add folder creation logic if needed
|
| 160 |
+
break
|
| 161 |
+
case 'refresh':
|
| 162 |
+
window.location.reload()
|
| 163 |
+
break
|
| 164 |
+
}
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
const triggerMatrix = () => {
|
| 168 |
+
setMatrixActive(true)
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
const handleMatrixComplete = () => {
|
| 172 |
+
setMatrixActive(false)
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
const openHelpModal = () => {
|
| 176 |
+
setHelpModalOpen(true)
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
const closeHelpModal = () => {
|
| 180 |
+
setHelpModalOpen(false)
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
const handleDesktopRightClick = (e: React.MouseEvent) => {
|
| 184 |
+
e.preventDefault()
|
| 185 |
+
setContextMenuPosition({ x: e.clientX, y: e.clientY })
|
| 186 |
+
setContextMenuVisible(true)
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
const handleChangeBackground = (type: 'upload' | 'preset') => {
|
| 190 |
+
setContextMenuOpen(false)
|
| 191 |
+
setBackgroundSelectorOpen(true)
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
const handleSelectBackground = (background: string | File) => {
|
| 195 |
+
if (typeof background === 'string') {
|
| 196 |
+
// Handle preset backgrounds
|
| 197 |
+
if (background.startsWith('gradient-')) {
|
| 198 |
+
setCurrentBackground(background)
|
| 199 |
+
} else {
|
| 200 |
+
setCurrentBackground(background)
|
| 201 |
+
}
|
| 202 |
+
} else {
|
| 203 |
+
// Handle uploaded file
|
| 204 |
+
const url = URL.createObjectURL(background)
|
| 205 |
+
setCurrentBackground(url)
|
| 206 |
+
}
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
// Keyboard shortcuts
|
| 210 |
+
useEffect(() => {
|
| 211 |
+
const handleKeyDown = (e: KeyboardEvent) => {
|
| 212 |
+
// Cmd/Ctrl + Space for Spotlight Search
|
| 213 |
+
if ((e.metaKey || e.ctrlKey) && e.key === ' ') {
|
| 214 |
+
e.preventDefault()
|
| 215 |
+
setSpotlightOpen(true)
|
| 216 |
+
}
|
| 217 |
+
// Cmd/Ctrl + K for Spotlight Search (alternative)
|
| 218 |
+
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
| 219 |
+
e.preventDefault()
|
| 220 |
+
setSpotlightOpen(true)
|
| 221 |
+
}
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
window.addEventListener('keydown', handleKeyDown)
|
| 225 |
+
return () => window.removeEventListener('keydown', handleKeyDown)
|
| 226 |
+
}, [])
|
| 227 |
+
|
| 228 |
+
const getBackgroundStyle = () => {
|
| 229 |
+
if (currentBackground.startsWith('gradient-')) {
|
| 230 |
+
const gradients: Record<string, string> = {
|
| 231 |
+
'gradient-purple': 'linear-gradient(135deg, #77216F 0%, #5E2750 50%, #2C001E 100%)',
|
| 232 |
+
'gradient-blue': 'linear-gradient(135deg, #1e3c72 0%, #2a5298 50%, #7e8ba3 100%)',
|
| 233 |
+
'gradient-green': 'linear-gradient(135deg, #134e5e 0%, #71b280 50%, #a8e063 100%)',
|
| 234 |
+
'gradient-orange': 'linear-gradient(135deg, #ff512f 0%, #dd2476 50%, #f09819 100%)',
|
| 235 |
+
'gradient-dark': 'linear-gradient(135deg, #0f0f0f 0%, #1a1a1a 50%, #2d2d2d 100%)',
|
| 236 |
+
'gradient-cosmic': 'linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%)'
|
| 237 |
+
}
|
| 238 |
+
return { background: gradients[currentBackground] || gradients['gradient-purple'] }
|
| 239 |
+
} else {
|
| 240 |
+
return {
|
| 241 |
+
backgroundImage: `url('${currentBackground}')`,
|
| 242 |
+
backgroundSize: 'cover',
|
| 243 |
+
backgroundPosition: 'center',
|
| 244 |
+
backgroundRepeat: 'no-repeat'
|
| 245 |
+
}
|
| 246 |
+
}
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
return (
|
| 250 |
+
<div className="relative h-screen w-screen overflow-hidden flex" onContextMenu={handleDesktopRightClick}>
|
| 251 |
+
<div
|
| 252 |
+
className="absolute inset-0"
|
| 253 |
+
style={getBackgroundStyle()}
|
| 254 |
+
>
|
| 255 |
+
{/* Overlay for better text visibility */}
|
| 256 |
+
{!currentBackground.startsWith('gradient-') && (
|
| 257 |
+
<div
|
| 258 |
+
className="absolute inset-0 bg-black/20"
|
| 259 |
+
style={{
|
| 260 |
+
mixBlendMode: 'multiply'
|
| 261 |
+
}}
|
| 262 |
+
/>
|
| 263 |
+
)}
|
| 264 |
+
<svg className="absolute inset-0 w-full h-full opacity-10" xmlns="http://www.w3.org/2000/svg">
|
| 265 |
+
<defs>
|
| 266 |
+
<pattern id="ubuntu-pattern" x="0" y="0" width="100" height="100" patternUnits="userSpaceOnUse">
|
| 267 |
+
<circle cx="50" cy="50" r="2" fill="white" opacity="0.3"/>
|
| 268 |
+
</pattern>
|
| 269 |
+
</defs>
|
| 270 |
+
<rect width="100%" height="100%" fill="url(#ubuntu-pattern)" />
|
| 271 |
+
</svg>
|
| 272 |
+
</div>
|
| 273 |
+
|
| 274 |
+
<TopBar onPowerAction={triggerMatrix} onAboutClick={() => setAboutModalOpen(true)} />
|
| 275 |
+
|
| 276 |
+
<Dock onOpenFileManager={openFileManager} onOpenCalendar={openCalendar} onOpenClock={openClock} onOpenBrowser={openBrowser} onOpenGeminiChat={openGeminiChat} onOpenVSCode={openVSCode} onOpenCodePlayground={openCodePlayground} />
|
| 277 |
+
|
| 278 |
+
<div className="flex-1">
|
| 279 |
+
{/* Desktop Icons - Positioned in a grid layout */}
|
| 280 |
+
<div className="absolute top-20 left-8">
|
| 281 |
+
<DraggableDesktopIcon
|
| 282 |
+
id="files"
|
| 283 |
+
label="Files"
|
| 284 |
+
iconType="files"
|
| 285 |
+
initialPosition={{ x: 20, y: 20 }}
|
| 286 |
+
onClick={() => {}}
|
| 287 |
+
onDoubleClick={() => openFileManager('')}
|
| 288 |
+
/>
|
| 289 |
+
<DraggableDesktopIcon
|
| 290 |
+
id="browser"
|
| 291 |
+
label="Browser"
|
| 292 |
+
iconType="browser"
|
| 293 |
+
initialPosition={{ x: 120, y: 20 }}
|
| 294 |
+
onClick={() => {}}
|
| 295 |
+
onDoubleClick={openBrowser}
|
| 296 |
+
/>
|
| 297 |
+
<DraggableDesktopIcon
|
| 298 |
+
id="gemini"
|
| 299 |
+
label="Gemini"
|
| 300 |
+
iconType="gemini"
|
| 301 |
+
initialPosition={{ x: 220, y: 20 }}
|
| 302 |
+
onClick={() => {}}
|
| 303 |
+
onDoubleClick={openGeminiChat}
|
| 304 |
+
/>
|
| 305 |
+
<DraggableDesktopIcon
|
| 306 |
+
id="clock"
|
| 307 |
+
label="Clock"
|
| 308 |
+
iconType="clock"
|
| 309 |
+
initialPosition={{ x: 20, y: 120 }}
|
| 310 |
+
onClick={() => {}}
|
| 311 |
+
onDoubleClick={openClock}
|
| 312 |
+
/>
|
| 313 |
+
<DraggableDesktopIcon
|
| 314 |
+
id="calendar"
|
| 315 |
+
label="Calendar"
|
| 316 |
+
iconType="calendar"
|
| 317 |
+
initialPosition={{ x: 120, y: 120 }}
|
| 318 |
+
onClick={() => {}}
|
| 319 |
+
onDoubleClick={openCalendar}
|
| 320 |
+
/>
|
| 321 |
+
<DraggableDesktopIcon
|
| 322 |
+
id="terminal"
|
| 323 |
+
label="Terminal"
|
| 324 |
+
iconType="terminal"
|
| 325 |
+
initialPosition={{ x: 20, y: 220 }}
|
| 326 |
+
onClick={() => {}}
|
| 327 |
+
onDoubleClick={openTerminal}
|
| 328 |
+
/>
|
| 329 |
+
<DraggableDesktopIcon
|
| 330 |
+
id="harddrive"
|
| 331 |
+
label="System"
|
| 332 |
+
iconType="harddrive"
|
| 333 |
+
initialPosition={{ x: 120, y: 220 }}
|
| 334 |
+
onClick={() => {}}
|
| 335 |
+
onDoubleClick={() => openFileManager('')}
|
| 336 |
+
/>
|
| 337 |
+
<DraggableDesktopIcon
|
| 338 |
+
id="vscode"
|
| 339 |
+
label="VS Code"
|
| 340 |
+
iconType="vscode"
|
| 341 |
+
initialPosition={{ x: 320, y: 20 }}
|
| 342 |
+
onClick={() => {}}
|
| 343 |
+
onDoubleClick={openVSCode}
|
| 344 |
+
/>
|
| 345 |
+
<DraggableDesktopIcon
|
| 346 |
+
id="playground"
|
| 347 |
+
label="Code Playground"
|
| 348 |
+
iconType="playground"
|
| 349 |
+
initialPosition={{ x: 220, y: 220 }}
|
| 350 |
+
onClick={() => {}}
|
| 351 |
+
onDoubleClick={openCodePlayground}
|
| 352 |
+
/>
|
| 353 |
+
<DraggableDesktopIcon
|
| 354 |
+
id="executor"
|
| 355 |
+
label="Code Executor"
|
| 356 |
+
iconType="terminal"
|
| 357 |
+
initialPosition={{ x: 320, y: 120 }}
|
| 358 |
+
onClick={() => {}}
|
| 359 |
+
onDoubleClick={openCodeExecutor}
|
| 360 |
+
/>
|
| 361 |
+
</div>
|
| 362 |
+
|
| 363 |
+
{fileManagerOpen && (
|
| 364 |
+
<motion.div
|
| 365 |
+
initial={{ scale: 0.95, opacity: 0 }}
|
| 366 |
+
animate={{ scale: 1, opacity: 1 }}
|
| 367 |
+
transition={{ duration: 0.15 }}
|
| 368 |
+
>
|
| 369 |
+
<FileManager
|
| 370 |
+
currentPath={currentPath}
|
| 371 |
+
onNavigate={setCurrentPath}
|
| 372 |
+
onClose={closeFileManager}
|
| 373 |
+
/>
|
| 374 |
+
</motion.div>
|
| 375 |
+
)}
|
| 376 |
+
|
| 377 |
+
{calendarOpen && (
|
| 378 |
+
<motion.div
|
| 379 |
+
initial={{ scale: 0.95, opacity: 0 }}
|
| 380 |
+
animate={{ scale: 1, opacity: 1 }}
|
| 381 |
+
transition={{ duration: 0.15 }}
|
| 382 |
+
>
|
| 383 |
+
<Calendar onClose={closeCalendar} />
|
| 384 |
+
</motion.div>
|
| 385 |
+
)}
|
| 386 |
+
|
| 387 |
+
{clockOpen && (
|
| 388 |
+
<motion.div
|
| 389 |
+
initial={{ scale: 0.95, opacity: 0 }}
|
| 390 |
+
animate={{ scale: 1, opacity: 1 }}
|
| 391 |
+
transition={{ duration: 0.15 }}
|
| 392 |
+
>
|
| 393 |
+
<Clock onClose={closeClock} />
|
| 394 |
+
</motion.div>
|
| 395 |
+
)}
|
| 396 |
+
|
| 397 |
+
{browserOpen && (
|
| 398 |
+
<motion.div
|
| 399 |
+
initial={{ scale: 0.95, opacity: 0 }}
|
| 400 |
+
animate={{ scale: 1, opacity: 1 }}
|
| 401 |
+
transition={{ duration: 0.15 }}
|
| 402 |
+
>
|
| 403 |
+
<WebBrowserApp onClose={closeBrowser} />
|
| 404 |
+
</motion.div>
|
| 405 |
+
)}
|
| 406 |
+
|
| 407 |
+
{geminiChatOpen && (
|
| 408 |
+
<GeminiChat onClose={closeGeminiChat} />
|
| 409 |
+
)}
|
| 410 |
+
|
| 411 |
+
{terminalOpen && (
|
| 412 |
+
<motion.div
|
| 413 |
+
initial={{ scale: 0.95, opacity: 0 }}
|
| 414 |
+
animate={{ scale: 1, opacity: 1 }}
|
| 415 |
+
transition={{ duration: 0.15 }}
|
| 416 |
+
>
|
| 417 |
+
<Terminal onClose={closeTerminal} />
|
| 418 |
+
</motion.div>
|
| 419 |
+
)}
|
| 420 |
+
|
| 421 |
+
{vscodeOpen && (
|
| 422 |
+
<motion.div
|
| 423 |
+
initial={{ scale: 0.95, opacity: 0 }}
|
| 424 |
+
animate={{ scale: 1, opacity: 1 }}
|
| 425 |
+
transition={{ duration: 0.15 }}
|
| 426 |
+
>
|
| 427 |
+
<VSCodeEditor onClose={closeVSCode} userSession={userSession} />
|
| 428 |
+
</motion.div>
|
| 429 |
+
)}
|
| 430 |
+
|
| 431 |
+
{codePlaygroundOpen && (
|
| 432 |
+
<motion.div
|
| 433 |
+
initial={{ scale: 0.95, opacity: 0 }}
|
| 434 |
+
animate={{ scale: 1, opacity: 1 }}
|
| 435 |
+
transition={{ duration: 0.15 }}
|
| 436 |
+
>
|
| 437 |
+
<CodePlayground onClose={closeCodePlayground} userSession={userSession} />
|
| 438 |
+
</motion.div>
|
| 439 |
+
)}
|
| 440 |
+
|
| 441 |
+
{codeExecutorOpen && (
|
| 442 |
+
<motion.div
|
| 443 |
+
initial={{ scale: 0.95, opacity: 0 }}
|
| 444 |
+
animate={{ scale: 1, opacity: 1 }}
|
| 445 |
+
transition={{ duration: 0.15 }}
|
| 446 |
+
>
|
| 447 |
+
<CodeExecutor onClose={closeCodeExecutor} />
|
| 448 |
+
</motion.div>
|
| 449 |
+
)}
|
| 450 |
+
</div>
|
| 451 |
+
|
| 452 |
+
{/* Spotlight Search */}
|
| 453 |
+
<SpotlightSearch
|
| 454 |
+
isOpen={spotlightOpen}
|
| 455 |
+
onClose={() => setSpotlightOpen(false)}
|
| 456 |
+
onOpenApp={handleOpenApp}
|
| 457 |
+
/>
|
| 458 |
+
|
| 459 |
+
{/* Context Menu */}
|
| 460 |
+
<ContextMenu
|
| 461 |
+
isOpen={contextMenuVisible}
|
| 462 |
+
position={contextMenuPosition}
|
| 463 |
+
onClose={() => setContextMenuVisible(false)}
|
| 464 |
+
onAction={handleContextMenuAction}
|
| 465 |
+
/>
|
| 466 |
+
|
| 467 |
+
{/* Matrix Rain Effect */}
|
| 468 |
+
<div onClick={handleMatrixComplete} style={{ cursor: matrixActive ? 'pointer' : 'default' }}>
|
| 469 |
+
<MatrixRain isActive={matrixActive} onComplete={handleMatrixComplete} />
|
| 470 |
+
</div>
|
| 471 |
+
|
| 472 |
+
{/* Help Modal */}
|
| 473 |
+
<HelpModal isOpen={helpModalOpen} onClose={closeHelpModal} />
|
| 474 |
+
|
| 475 |
+
{/* Desktop Context Menu */}
|
| 476 |
+
<DesktopContextMenu
|
| 477 |
+
isOpen={contextMenuOpen}
|
| 478 |
+
position={contextMenuPos}
|
| 479 |
+
onClose={() => setContextMenuOpen(false)}
|
| 480 |
+
onChangeBackground={handleChangeBackground}
|
| 481 |
+
/>
|
| 482 |
+
|
| 483 |
+
{/* Background Selector Modal */}
|
| 484 |
+
<BackgroundSelector
|
| 485 |
+
isOpen={backgroundSelectorOpen}
|
| 486 |
+
onClose={() => setBackgroundSelectorOpen(false)}
|
| 487 |
+
onSelectBackground={handleSelectBackground}
|
| 488 |
+
currentBackground={currentBackground}
|
| 489 |
+
/>
|
| 490 |
+
|
| 491 |
+
{/* About Modal */}
|
| 492 |
+
<AboutModal
|
| 493 |
+
isOpen={aboutModalOpen}
|
| 494 |
+
onClose={() => setAboutModalOpen(false)}
|
| 495 |
+
/>
|
| 496 |
+
</div>
|
| 497 |
+
)
|
| 498 |
+
}
|
app/components/DesktopContextMenu.tsx
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import React, { useRef, useEffect } from 'react'
|
| 4 |
+
import { motion, AnimatePresence } from 'framer-motion'
|
| 5 |
+
import {
|
| 6 |
+
Image,
|
| 7 |
+
PaintBrush,
|
| 8 |
+
Upload,
|
| 9 |
+
Palette,
|
| 10 |
+
Desktop as DesktopIcon,
|
| 11 |
+
Gradient
|
| 12 |
+
} from '@phosphor-icons/react'
|
| 13 |
+
|
| 14 |
+
interface DesktopContextMenuProps {
|
| 15 |
+
isOpen: boolean
|
| 16 |
+
position: { x: number; y: number }
|
| 17 |
+
onClose: () => void
|
| 18 |
+
onChangeBackground: (type: 'upload' | 'preset') => void
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
const presetBackgrounds = [
|
| 22 |
+
{ name: 'Ubuntu Purple', value: 'gradient-purple' },
|
| 23 |
+
{ name: 'Ocean Blue', value: 'gradient-blue' },
|
| 24 |
+
{ name: 'Forest Green', value: 'gradient-green' },
|
| 25 |
+
{ name: 'Sunset Orange', value: 'gradient-orange' },
|
| 26 |
+
{ name: 'Dark Mode', value: 'gradient-dark' },
|
| 27 |
+
]
|
| 28 |
+
|
| 29 |
+
export function DesktopContextMenu({
|
| 30 |
+
isOpen,
|
| 31 |
+
position,
|
| 32 |
+
onClose,
|
| 33 |
+
onChangeBackground
|
| 34 |
+
}: DesktopContextMenuProps) {
|
| 35 |
+
const menuRef = useRef<HTMLDivElement>(null)
|
| 36 |
+
|
| 37 |
+
useEffect(() => {
|
| 38 |
+
const handleClickOutside = (event: MouseEvent) => {
|
| 39 |
+
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
| 40 |
+
onClose()
|
| 41 |
+
}
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
const handleEscape = (event: KeyboardEvent) => {
|
| 45 |
+
if (event.key === 'Escape') {
|
| 46 |
+
onClose()
|
| 47 |
+
}
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
if (isOpen) {
|
| 51 |
+
document.addEventListener('mousedown', handleClickOutside)
|
| 52 |
+
document.addEventListener('keydown', handleEscape)
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
return () => {
|
| 56 |
+
document.removeEventListener('mousedown', handleClickOutside)
|
| 57 |
+
document.removeEventListener('keydown', handleEscape)
|
| 58 |
+
}
|
| 59 |
+
}, [isOpen, onClose])
|
| 60 |
+
|
| 61 |
+
return (
|
| 62 |
+
<AnimatePresence>
|
| 63 |
+
{isOpen && (
|
| 64 |
+
<motion.div
|
| 65 |
+
ref={menuRef}
|
| 66 |
+
initial={{ opacity: 0, scale: 0.95 }}
|
| 67 |
+
animate={{ opacity: 1, scale: 1 }}
|
| 68 |
+
exit={{ opacity: 0, scale: 0.95 }}
|
| 69 |
+
transition={{ duration: 0.1 }}
|
| 70 |
+
className="fixed z-50 bg-[#2C2C2C]/95 backdrop-blur-md rounded-lg shadow-2xl border border-white/10 py-2 min-w-[240px]"
|
| 71 |
+
style={{
|
| 72 |
+
left: `${position.x}px`,
|
| 73 |
+
top: `${position.y}px`,
|
| 74 |
+
maxHeight: 'calc(100vh - 20px)',
|
| 75 |
+
overflowY: 'auto'
|
| 76 |
+
}}
|
| 77 |
+
>
|
| 78 |
+
{/* Change Background Section */}
|
| 79 |
+
<div className="px-3 py-1.5 text-xs font-medium text-gray-400 uppercase tracking-wider">
|
| 80 |
+
Change Background
|
| 81 |
+
</div>
|
| 82 |
+
|
| 83 |
+
<button
|
| 84 |
+
onClick={() => onChangeBackground('upload')}
|
| 85 |
+
className="w-full px-3 py-2 flex items-center gap-3 hover:bg-white/10 transition-colors text-left"
|
| 86 |
+
>
|
| 87 |
+
<Upload size={18} className="text-blue-400" />
|
| 88 |
+
<span className="text-sm text-gray-200">Upload Image</span>
|
| 89 |
+
</button>
|
| 90 |
+
|
| 91 |
+
<button
|
| 92 |
+
onClick={() => onChangeBackground('preset')}
|
| 93 |
+
className="w-full px-3 py-2 flex items-center gap-3 hover:bg-white/10 transition-colors text-left"
|
| 94 |
+
>
|
| 95 |
+
<Palette size={18} className="text-purple-400" />
|
| 96 |
+
<span className="text-sm text-gray-200">Choose from Gallery</span>
|
| 97 |
+
</button>
|
| 98 |
+
|
| 99 |
+
<div className="h-px bg-white/10 my-2" />
|
| 100 |
+
|
| 101 |
+
{/* Display Settings */}
|
| 102 |
+
<div className="px-3 py-1.5 text-xs font-medium text-gray-400 uppercase tracking-wider">
|
| 103 |
+
Display
|
| 104 |
+
</div>
|
| 105 |
+
|
| 106 |
+
<button
|
| 107 |
+
className="w-full px-3 py-2 flex items-center gap-3 hover:bg-white/10 transition-colors text-left"
|
| 108 |
+
>
|
| 109 |
+
<DesktopIcon size={18} className="text-green-400" />
|
| 110 |
+
<span className="text-sm text-gray-200">Desktop Icons</span>
|
| 111 |
+
</button>
|
| 112 |
+
|
| 113 |
+
<button
|
| 114 |
+
className="w-full px-3 py-2 flex items-center gap-3 hover:bg-white/10 transition-colors text-left"
|
| 115 |
+
>
|
| 116 |
+
<Gradient size={18} className="text-orange-400" />
|
| 117 |
+
<span className="text-sm text-gray-200">Visual Effects</span>
|
| 118 |
+
</button>
|
| 119 |
+
</motion.div>
|
| 120 |
+
)}
|
| 121 |
+
</AnimatePresence>
|
| 122 |
+
)
|
| 123 |
+
}
|
app/components/DesktopIcon.tsx
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import React from 'react'
|
| 4 |
+
import { House, Trash, Calendar as CalendarIcon, Clock, Folder, Globe, Sparkle, GameController } from '@phosphor-icons/react'
|
| 5 |
+
|
| 6 |
+
interface DesktopIconProps {
|
| 7 |
+
icon: 'home' | 'trash' | 'calendar' | 'clock' | 'game' | 'folder' | 'browser' | 'gemini'
|
| 8 |
+
label: string
|
| 9 |
+
onClick: () => void
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
export function DesktopIcon({ icon, label, onClick }: DesktopIconProps) {
|
| 13 |
+
const getIcon = () => {
|
| 14 |
+
switch(icon) {
|
| 15 |
+
case 'home': return House
|
| 16 |
+
case 'calendar': return CalendarIcon
|
| 17 |
+
case 'clock': return Clock
|
| 18 |
+
case 'game': return GameController
|
| 19 |
+
case 'folder': return Folder
|
| 20 |
+
case 'browser': return Globe
|
| 21 |
+
case 'gemini': return Sparkle
|
| 22 |
+
case 'trash': return Trash
|
| 23 |
+
default: return Folder
|
| 24 |
+
}
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
const Icon = getIcon()
|
| 28 |
+
|
| 29 |
+
const getGradient = () => {
|
| 30 |
+
if (icon === 'calendar') {
|
| 31 |
+
return 'from-purple-500/80 to-purple-700/80 group-hover:from-purple-400/80 group-hover:to-purple-600/80'
|
| 32 |
+
}
|
| 33 |
+
if (icon === 'clock') {
|
| 34 |
+
return 'from-teal-500/80 to-cyan-600/80 group-hover:from-teal-400/80 group-hover:to-cyan-500/80'
|
| 35 |
+
}
|
| 36 |
+
if (icon === 'game') {
|
| 37 |
+
return 'from-pink-500/80 to-rose-600/80 group-hover:from-pink-400/80 group-hover:to-rose-500/80'
|
| 38 |
+
}
|
| 39 |
+
if (icon === 'folder') {
|
| 40 |
+
return 'from-orange-500/80 to-orange-700/80 group-hover:from-orange-400/80 group-hover:to-orange-600/80'
|
| 41 |
+
}
|
| 42 |
+
if (icon === 'browser') {
|
| 43 |
+
return 'from-blue-500/80 to-cyan-600/80 group-hover:from-blue-400/80 group-hover:to-cyan-500/80'
|
| 44 |
+
}
|
| 45 |
+
if (icon === 'gemini') {
|
| 46 |
+
return 'from-[#E95420]/80 to-[#d14818]/80 group-hover:from-[#E95420] group-hover:to-[#d14818]'
|
| 47 |
+
}
|
| 48 |
+
return 'from-gray-600/80 to-gray-700/80 group-hover:from-gray-500/80 group-hover:to-gray-600/80'
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
return (
|
| 52 |
+
<button
|
| 53 |
+
onClick={onClick}
|
| 54 |
+
className="flex flex-col items-center gap-1 p-2 rounded hover:bg-white/10 transition-colors w-24 group mb-2"
|
| 55 |
+
>
|
| 56 |
+
<div className={`w-16 h-16 rounded-lg bg-gradient-to-br ${getGradient()} backdrop-blur-sm flex items-center justify-center transition-all`}>
|
| 57 |
+
<Icon size={32} weight="regular" className="text-white" />
|
| 58 |
+
</div>
|
| 59 |
+
<span className="text-white text-xs text-center drop-shadow-[0_1px_2px_rgba(0,0,0,0.8)] font-medium">
|
| 60 |
+
{label}
|
| 61 |
+
</span>
|
| 62 |
+
</button>
|
| 63 |
+
)
|
| 64 |
+
}
|
app/components/Dock.tsx
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import React, { useState } from 'react'
|
| 4 |
+
import {
|
| 5 |
+
Folder,
|
| 6 |
+
Calendar as CalendarIcon,
|
| 7 |
+
Clock as ClockIcon,
|
| 8 |
+
Globe,
|
| 9 |
+
Sparkle,
|
| 10 |
+
Trash,
|
| 11 |
+
FolderOpen,
|
| 12 |
+
Compass,
|
| 13 |
+
Code,
|
| 14 |
+
Lightning
|
| 15 |
+
} from '@phosphor-icons/react'
|
| 16 |
+
|
| 17 |
+
interface DockProps {
|
| 18 |
+
onOpenFileManager: (path: string) => void
|
| 19 |
+
onOpenCalendar: () => void
|
| 20 |
+
onOpenClock: () => void
|
| 21 |
+
onOpenBrowser: () => void
|
| 22 |
+
onOpenGeminiChat: () => void
|
| 23 |
+
onOpenVSCode?: () => void
|
| 24 |
+
onOpenCodePlayground?: () => void
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
interface DockItemProps {
|
| 28 |
+
icon: React.ReactNode
|
| 29 |
+
label: string
|
| 30 |
+
onClick: () => void
|
| 31 |
+
isActive?: boolean
|
| 32 |
+
className?: string
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
const DockItem: React.FC<DockItemProps> = ({ icon, label, onClick, isActive = false, className = '' }) => {
|
| 36 |
+
const [isHovered, setIsHovered] = useState(false)
|
| 37 |
+
|
| 38 |
+
return (
|
| 39 |
+
<div className="dock-item group">
|
| 40 |
+
<button
|
| 41 |
+
onMouseEnter={() => setIsHovered(true)}
|
| 42 |
+
onMouseLeave={() => setIsHovered(false)}
|
| 43 |
+
onClick={onClick}
|
| 44 |
+
className={`app-icon w-12 h-12 rounded-xl shadow-lg flex items-center justify-center cursor-pointer border transition-all ${className}`}
|
| 45 |
+
title={label}
|
| 46 |
+
>
|
| 47 |
+
{icon}
|
| 48 |
+
</button>
|
| 49 |
+
<div
|
| 50 |
+
className={`dock-dot ${isActive ? 'opacity-100' : ''}`}
|
| 51 |
+
id={`dot-${label.toLowerCase().replace(/\s+/g, '-')}`}
|
| 52 |
+
/>
|
| 53 |
+
</div>
|
| 54 |
+
)
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
export function Dock({
|
| 58 |
+
onOpenFileManager,
|
| 59 |
+
onOpenCalendar,
|
| 60 |
+
onOpenClock,
|
| 61 |
+
onOpenBrowser,
|
| 62 |
+
onOpenGeminiChat,
|
| 63 |
+
onOpenVSCode,
|
| 64 |
+
onOpenCodePlayground
|
| 65 |
+
}: DockProps) {
|
| 66 |
+
const dockItems = [
|
| 67 |
+
{
|
| 68 |
+
icon: (
|
| 69 |
+
<div className="bg-gradient-to-br from-blue-400 to-cyan-200 w-full h-full rounded-xl flex items-center justify-center border border-white/30">
|
| 70 |
+
<Folder size={28} weight="regular" className="text-blue-900" />
|
| 71 |
+
</div>
|
| 72 |
+
),
|
| 73 |
+
label: 'Files',
|
| 74 |
+
onClick: () => onOpenFileManager(''),
|
| 75 |
+
className: ''
|
| 76 |
+
},
|
| 77 |
+
{
|
| 78 |
+
icon: (
|
| 79 |
+
<div className="bg-white w-full h-full rounded-xl overflow-hidden relative flex items-center justify-center">
|
| 80 |
+
<div className="absolute inset-0 bg-gradient-to-tr from-blue-500 to-cyan-300" />
|
| 81 |
+
<Compass size={36} weight="light" className="text-white relative z-10" />
|
| 82 |
+
</div>
|
| 83 |
+
),
|
| 84 |
+
label: 'Browser',
|
| 85 |
+
onClick: onOpenBrowser,
|
| 86 |
+
className: ''
|
| 87 |
+
},
|
| 88 |
+
{
|
| 89 |
+
icon: (
|
| 90 |
+
<div className="bg-white w-full h-full rounded-xl flex items-center justify-center border border-gray-200">
|
| 91 |
+
<Sparkle size={28} weight="fill" className="text-blue-500" />
|
| 92 |
+
</div>
|
| 93 |
+
),
|
| 94 |
+
label: 'Gemini',
|
| 95 |
+
onClick: onOpenGeminiChat,
|
| 96 |
+
className: ''
|
| 97 |
+
},
|
| 98 |
+
{
|
| 99 |
+
icon: (
|
| 100 |
+
<div className="bg-black w-full h-full rounded-full flex items-center justify-center border border-gray-600">
|
| 101 |
+
<div className="w-10 h-10 rounded-full border border-gray-600 relative bg-black">
|
| 102 |
+
<div className="absolute top-1/2 left-1/2 w-0.5 h-2.5 bg-white origin-bottom -translate-x-1/2 -translate-y-full" />
|
| 103 |
+
<div className="absolute top-1/2 left-1/2 w-0.5 h-3.5 bg-gray-300 origin-bottom -translate-x-1/2 -translate-y-full" />
|
| 104 |
+
<div className="absolute top-1/2 left-1/2 w-0.5 h-4 bg-orange-500 origin-bottom -translate-x-1/2 -translate-y-full animate-spin" style={{ animationDuration: '60s' }} />
|
| 105 |
+
</div>
|
| 106 |
+
</div>
|
| 107 |
+
),
|
| 108 |
+
label: 'Clock',
|
| 109 |
+
onClick: onOpenClock,
|
| 110 |
+
className: 'rounded-full'
|
| 111 |
+
},
|
| 112 |
+
{
|
| 113 |
+
icon: (
|
| 114 |
+
<div className="bg-gradient-to-br from-purple-500 to-purple-600 w-full h-full rounded-xl flex items-center justify-center shadow-inner">
|
| 115 |
+
<CalendarIcon size={28} weight="regular" className="text-white" />
|
| 116 |
+
</div>
|
| 117 |
+
),
|
| 118 |
+
label: 'Calendar',
|
| 119 |
+
onClick: onOpenCalendar,
|
| 120 |
+
className: ''
|
| 121 |
+
},
|
| 122 |
+
{
|
| 123 |
+
icon: (
|
| 124 |
+
<div className="bg-gradient-to-br from-blue-600 to-blue-800 w-full h-full rounded-xl flex items-center justify-center border border-blue-900/30 shadow-inner">
|
| 125 |
+
<Code size={28} weight="bold" className="text-white" />
|
| 126 |
+
</div>
|
| 127 |
+
),
|
| 128 |
+
label: 'VS Code',
|
| 129 |
+
onClick: onOpenVSCode || (() => {}),
|
| 130 |
+
className: ''
|
| 131 |
+
},
|
| 132 |
+
{
|
| 133 |
+
icon: (
|
| 134 |
+
<div className="bg-gradient-to-br from-yellow-500 to-orange-600 w-full h-full rounded-xl flex items-center justify-center border border-orange-700/30 shadow-inner">
|
| 135 |
+
<Lightning size={28} weight="fill" className="text-white" />
|
| 136 |
+
</div>
|
| 137 |
+
),
|
| 138 |
+
label: 'Code Playground',
|
| 139 |
+
onClick: onOpenCodePlayground || (() => {}),
|
| 140 |
+
className: ''
|
| 141 |
+
}
|
| 142 |
+
]
|
| 143 |
+
|
| 144 |
+
return (
|
| 145 |
+
<div className="dock-container">
|
| 146 |
+
<div className="dock-glass px-3 pb-2 pt-3 rounded-2xl flex items-end gap-2 shadow-2xl border border-white/20 transition-all duration-300">
|
| 147 |
+
{dockItems.map((item, index) => (
|
| 148 |
+
<React.Fragment key={item.label}>
|
| 149 |
+
<DockItem {...item} />
|
| 150 |
+
{index === dockItems.length - 1 && (
|
| 151 |
+
<>
|
| 152 |
+
<div className="w-px h-10 bg-gray-400/30 mx-1" />
|
| 153 |
+
<DockItem
|
| 154 |
+
icon={
|
| 155 |
+
<div className="bg-gradient-to-b from-gray-200 to-gray-300 w-full h-full rounded-xl flex items-center justify-center border border-white/40">
|
| 156 |
+
<Trash size={24} weight="regular" className="text-gray-600" />
|
| 157 |
+
</div>
|
| 158 |
+
}
|
| 159 |
+
label="Trash"
|
| 160 |
+
onClick={() => {}}
|
| 161 |
+
className=""
|
| 162 |
+
/>
|
| 163 |
+
</>
|
| 164 |
+
)}
|
| 165 |
+
</React.Fragment>
|
| 166 |
+
))}
|
| 167 |
+
</div>
|
| 168 |
+
</div>
|
| 169 |
+
)
|
| 170 |
+
}
|
app/components/DraggableDesktopIcon.tsx
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import React, { useState, useRef } from 'react'
|
| 4 |
+
import Draggable from 'react-draggable'
|
| 5 |
+
import {
|
| 6 |
+
Folder,
|
| 7 |
+
Calendar,
|
| 8 |
+
Clock,
|
| 9 |
+
Globe,
|
| 10 |
+
Sparkle,
|
| 11 |
+
Terminal,
|
| 12 |
+
HardDrives,
|
| 13 |
+
Compass,
|
| 14 |
+
Code,
|
| 15 |
+
Lightning
|
| 16 |
+
} from '@phosphor-icons/react'
|
| 17 |
+
|
| 18 |
+
interface DraggableDesktopIconProps {
|
| 19 |
+
id: string
|
| 20 |
+
label: string
|
| 21 |
+
iconType: string
|
| 22 |
+
initialPosition?: { x: number; y: number }
|
| 23 |
+
onClick: () => void
|
| 24 |
+
onDoubleClick: () => void
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
export function DraggableDesktopIcon({
|
| 28 |
+
id,
|
| 29 |
+
label,
|
| 30 |
+
iconType,
|
| 31 |
+
initialPosition = { x: 0, y: 0 },
|
| 32 |
+
onClick,
|
| 33 |
+
onDoubleClick
|
| 34 |
+
}: DraggableDesktopIconProps) {
|
| 35 |
+
const [selected, setSelected] = useState(false)
|
| 36 |
+
const nodeRef = useRef<HTMLDivElement>(null) as React.MutableRefObject<HTMLDivElement>
|
| 37 |
+
|
| 38 |
+
const handleClick = () => {
|
| 39 |
+
setSelected(true)
|
| 40 |
+
onClick()
|
| 41 |
+
// Deselect after a short time
|
| 42 |
+
setTimeout(() => setSelected(false), 3000)
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
const getIcon = () => {
|
| 46 |
+
switch (iconType) {
|
| 47 |
+
case 'files':
|
| 48 |
+
return (
|
| 49 |
+
<div className="bg-gradient-to-br from-blue-400 to-cyan-200 w-full h-full rounded-xl flex items-center justify-center border border-white/30">
|
| 50 |
+
<Folder size={32} weight="regular" className="text-blue-900" />
|
| 51 |
+
</div>
|
| 52 |
+
)
|
| 53 |
+
case 'calendar':
|
| 54 |
+
return (
|
| 55 |
+
<div className="bg-gradient-to-br from-purple-500 to-purple-600 w-full h-full rounded-xl flex items-center justify-center shadow-inner">
|
| 56 |
+
<Calendar size={32} weight="regular" className="text-white" />
|
| 57 |
+
</div>
|
| 58 |
+
)
|
| 59 |
+
case 'clock':
|
| 60 |
+
return (
|
| 61 |
+
<div className="bg-black w-full h-full rounded-full flex items-center justify-center border-2 border-gray-600">
|
| 62 |
+
<Clock size={32} weight="regular" className="text-white" />
|
| 63 |
+
</div>
|
| 64 |
+
)
|
| 65 |
+
case 'browser':
|
| 66 |
+
return (
|
| 67 |
+
<div className="bg-white w-full h-full rounded-xl overflow-hidden relative flex items-center justify-center">
|
| 68 |
+
<div className="absolute inset-0 bg-gradient-to-tr from-blue-500 to-cyan-300" />
|
| 69 |
+
<Compass size={36} weight="light" className="text-white relative z-10" />
|
| 70 |
+
</div>
|
| 71 |
+
)
|
| 72 |
+
case 'gemini':
|
| 73 |
+
return (
|
| 74 |
+
<div className="bg-white w-full h-full rounded-xl flex items-center justify-center border border-gray-200">
|
| 75 |
+
<Sparkle size={32} weight="fill" className="text-blue-500" />
|
| 76 |
+
</div>
|
| 77 |
+
)
|
| 78 |
+
case 'terminal':
|
| 79 |
+
return (
|
| 80 |
+
<div className="bg-gray-800 w-full h-full rounded-xl flex items-center justify-center border border-gray-600">
|
| 81 |
+
<span className="text-white font-mono font-bold text-xl">>_</span>
|
| 82 |
+
</div>
|
| 83 |
+
)
|
| 84 |
+
case 'harddrive':
|
| 85 |
+
return (
|
| 86 |
+
<div className="bg-gray-200 w-full h-full rounded-lg flex items-center justify-center border border-gray-300 relative">
|
| 87 |
+
<HardDrives size={32} weight="regular" className="text-gray-600" />
|
| 88 |
+
<div className="absolute bottom-2 right-2 w-1.5 h-1.5 bg-green-400 rounded-full animate-pulse" />
|
| 89 |
+
</div>
|
| 90 |
+
)
|
| 91 |
+
case 'vscode':
|
| 92 |
+
return (
|
| 93 |
+
<div className="bg-gradient-to-br from-blue-600 to-blue-800 w-full h-full rounded-xl flex items-center justify-center border border-blue-900/30 shadow-inner">
|
| 94 |
+
<Code size={32} weight="bold" className="text-white" />
|
| 95 |
+
</div>
|
| 96 |
+
)
|
| 97 |
+
case 'playground':
|
| 98 |
+
return (
|
| 99 |
+
<div className="bg-gradient-to-br from-yellow-500 to-orange-600 w-full h-full rounded-xl flex items-center justify-center border border-orange-700/30 shadow-inner">
|
| 100 |
+
<Lightning size={32} weight="fill" className="text-white" />
|
| 101 |
+
</div>
|
| 102 |
+
)
|
| 103 |
+
default:
|
| 104 |
+
return (
|
| 105 |
+
<div className="bg-gray-400 w-full h-full rounded-xl flex items-center justify-center">
|
| 106 |
+
<Folder size={32} weight="regular" className="text-white" />
|
| 107 |
+
</div>
|
| 108 |
+
)
|
| 109 |
+
}
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
return (
|
| 113 |
+
<Draggable
|
| 114 |
+
nodeRef={nodeRef}
|
| 115 |
+
defaultPosition={initialPosition}
|
| 116 |
+
grid={[25, 25]}
|
| 117 |
+
handle=".icon-handle"
|
| 118 |
+
>
|
| 119 |
+
<div
|
| 120 |
+
ref={nodeRef}
|
| 121 |
+
className="absolute flex flex-col items-center w-20 group cursor-pointer transition-all hover:bg-white/10 rounded p-1"
|
| 122 |
+
onClick={handleClick}
|
| 123 |
+
onDoubleClick={onDoubleClick}
|
| 124 |
+
>
|
| 125 |
+
<div className="icon-handle">
|
| 126 |
+
<div
|
| 127 |
+
className={`w-14 h-14 shadow-lg transition-transform hover:scale-110 ${
|
| 128 |
+
iconType === 'clock' ? '' : 'rounded-xl'
|
| 129 |
+
}`}
|
| 130 |
+
>
|
| 131 |
+
{getIcon()}
|
| 132 |
+
</div>
|
| 133 |
+
</div>
|
| 134 |
+
<span
|
| 135 |
+
className={`mt-1 text-xs font-medium drop-shadow-lg text-center leading-tight px-1 py-0.5 rounded transition-colors ${
|
| 136 |
+
selected
|
| 137 |
+
? 'bg-blue-600/80 text-white'
|
| 138 |
+
: 'text-white bg-black/20 group-hover:bg-blue-600/80'
|
| 139 |
+
}`}
|
| 140 |
+
>
|
| 141 |
+
{label}
|
| 142 |
+
</span>
|
| 143 |
+
</div>
|
| 144 |
+
</Draggable>
|
| 145 |
+
)
|
| 146 |
+
}
|
app/components/FileManager.tsx
ADDED
|
@@ -0,0 +1,542 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import React, { useState, useEffect } from 'react'
|
| 4 |
+
import {
|
| 5 |
+
X,
|
| 6 |
+
Minus,
|
| 7 |
+
Square,
|
| 8 |
+
CaretLeft,
|
| 9 |
+
CaretRight,
|
| 10 |
+
House,
|
| 11 |
+
MagnifyingGlass,
|
| 12 |
+
Folder as FolderIcon,
|
| 13 |
+
File,
|
| 14 |
+
Image as ImageIcon,
|
| 15 |
+
FilePdf,
|
| 16 |
+
FileDoc,
|
| 17 |
+
FileText,
|
| 18 |
+
Upload,
|
| 19 |
+
Download,
|
| 20 |
+
Trash,
|
| 21 |
+
Plus,
|
| 22 |
+
Eye,
|
| 23 |
+
Users,
|
| 24 |
+
Globe
|
| 25 |
+
} from '@phosphor-icons/react'
|
| 26 |
+
import { motion } from 'framer-motion'
|
| 27 |
+
import { FilePreview } from './FilePreview'
|
| 28 |
+
|
| 29 |
+
interface FileManagerProps {
|
| 30 |
+
currentPath: string
|
| 31 |
+
onNavigate: (path: string) => void
|
| 32 |
+
onClose: () => void
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
interface FileItem {
|
| 36 |
+
name: string
|
| 37 |
+
type: 'folder' | 'file'
|
| 38 |
+
size?: number
|
| 39 |
+
modified?: string
|
| 40 |
+
path: string
|
| 41 |
+
extension?: string
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
export function FileManager({ currentPath, onNavigate, onClose }: FileManagerProps) {
|
| 45 |
+
const [files, setFiles] = useState<FileItem[]>([])
|
| 46 |
+
const [loading, setLoading] = useState(true)
|
| 47 |
+
const [searchQuery, setSearchQuery] = useState('')
|
| 48 |
+
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set())
|
| 49 |
+
const [uploadModalOpen, setUploadModalOpen] = useState(false)
|
| 50 |
+
const [previewFile, setPreviewFile] = useState<FileItem | null>(null)
|
| 51 |
+
const [windowPos, setWindowPos] = useState({ x: 60, y: 60 })
|
| 52 |
+
const [isDragging, setIsDragging] = useState(false)
|
| 53 |
+
const [dragStart, setDragStart] = useState({ x: 0, y: 0 })
|
| 54 |
+
const [isPublicFolder, setIsPublicFolder] = useState(false)
|
| 55 |
+
|
| 56 |
+
// Load files when path changes
|
| 57 |
+
useEffect(() => {
|
| 58 |
+
// Check if this is the public folder
|
| 59 |
+
setIsPublicFolder(currentPath === 'public' || currentPath.startsWith('public/'))
|
| 60 |
+
loadFiles()
|
| 61 |
+
}, [currentPath])
|
| 62 |
+
|
| 63 |
+
const loadFiles = async () => {
|
| 64 |
+
setLoading(true)
|
| 65 |
+
try {
|
| 66 |
+
let response
|
| 67 |
+
if (currentPath === 'public' || currentPath.startsWith('public/')) {
|
| 68 |
+
// Load from public folder API
|
| 69 |
+
const publicPath = currentPath === 'public' ? '' : currentPath.replace('public/', '')
|
| 70 |
+
response = await fetch(`/api/public?folder=${encodeURIComponent(publicPath)}`)
|
| 71 |
+
} else {
|
| 72 |
+
// Load from regular files API
|
| 73 |
+
response = await fetch(`/api/files?folder=${encodeURIComponent(currentPath)}`)
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
const data = await response.json()
|
| 77 |
+
|
| 78 |
+
// Add public folder to root directory
|
| 79 |
+
if (currentPath === '') {
|
| 80 |
+
const publicFolder = {
|
| 81 |
+
name: 'Public Folder',
|
| 82 |
+
type: 'folder' as const,
|
| 83 |
+
path: 'public',
|
| 84 |
+
modified: new Date().toISOString()
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
// Add public folder if it doesn't exist
|
| 88 |
+
if (!data.files.some((f: FileItem) => f.path === 'public')) {
|
| 89 |
+
data.files.unshift(publicFolder)
|
| 90 |
+
}
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
setFiles(data.files || [])
|
| 94 |
+
} catch (error) {
|
| 95 |
+
console.error('Error loading files:', error)
|
| 96 |
+
setFiles([])
|
| 97 |
+
} finally {
|
| 98 |
+
setLoading(false)
|
| 99 |
+
}
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
const handleUpload = async (file: File, targetFolder: string) => {
|
| 103 |
+
const formData = new FormData()
|
| 104 |
+
formData.append('file', file)
|
| 105 |
+
|
| 106 |
+
try {
|
| 107 |
+
let response
|
| 108 |
+
if (isPublicFolder) {
|
| 109 |
+
// Upload to public folder
|
| 110 |
+
const publicPath = currentPath === 'public' ? '' : currentPath.replace('public/', '')
|
| 111 |
+
formData.append('folder', publicPath)
|
| 112 |
+
formData.append('uploadedBy', 'User') // You can customize this
|
| 113 |
+
response = await fetch('/api/public', {
|
| 114 |
+
method: 'POST',
|
| 115 |
+
body: formData
|
| 116 |
+
})
|
| 117 |
+
} else {
|
| 118 |
+
// Upload to regular folder
|
| 119 |
+
formData.append('folder', targetFolder)
|
| 120 |
+
response = await fetch('/api/upload', {
|
| 121 |
+
method: 'POST',
|
| 122 |
+
body: formData
|
| 123 |
+
})
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
const result = await response.json()
|
| 127 |
+
if (result.success) {
|
| 128 |
+
loadFiles() // Reload files
|
| 129 |
+
setUploadModalOpen(false)
|
| 130 |
+
} else {
|
| 131 |
+
alert(`Upload failed: ${result.error}`)
|
| 132 |
+
}
|
| 133 |
+
} catch (error) {
|
| 134 |
+
console.error('Error uploading file:', error)
|
| 135 |
+
alert('Failed to upload file')
|
| 136 |
+
}
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
const handleDownload = (file: FileItem) => {
|
| 140 |
+
window.open(`/api/download?path=${encodeURIComponent(file.path)}`, '_blank')
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
const handlePreview = (file: FileItem) => {
|
| 144 |
+
setPreviewFile(file)
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
const handleDelete = async (file: FileItem) => {
|
| 148 |
+
if (!confirm(`Delete ${file.name}?`)) return
|
| 149 |
+
|
| 150 |
+
try {
|
| 151 |
+
const response = await fetch(`/api/files?path=${encodeURIComponent(file.path)}`, {
|
| 152 |
+
method: 'DELETE'
|
| 153 |
+
})
|
| 154 |
+
|
| 155 |
+
const result = await response.json()
|
| 156 |
+
if (result.success) {
|
| 157 |
+
loadFiles()
|
| 158 |
+
} else {
|
| 159 |
+
alert(`Delete failed: ${result.error}`)
|
| 160 |
+
}
|
| 161 |
+
} catch (error) {
|
| 162 |
+
console.error('Error deleting file:', error)
|
| 163 |
+
alert('Failed to delete file')
|
| 164 |
+
}
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
const handleCreateFolder = async () => {
|
| 168 |
+
const folderName = prompt('Enter folder name:')
|
| 169 |
+
if (!folderName) return
|
| 170 |
+
|
| 171 |
+
try {
|
| 172 |
+
const response = await fetch('/api/files', {
|
| 173 |
+
method: 'POST',
|
| 174 |
+
headers: { 'Content-Type': 'application/json' },
|
| 175 |
+
body: JSON.stringify({
|
| 176 |
+
folderName,
|
| 177 |
+
parentPath: currentPath
|
| 178 |
+
})
|
| 179 |
+
})
|
| 180 |
+
|
| 181 |
+
const result = await response.json()
|
| 182 |
+
if (result.success) {
|
| 183 |
+
loadFiles()
|
| 184 |
+
} else {
|
| 185 |
+
alert(`Create folder failed: ${result.error}`)
|
| 186 |
+
}
|
| 187 |
+
} catch (error) {
|
| 188 |
+
console.error('Error creating folder:', error)
|
| 189 |
+
alert('Failed to create folder')
|
| 190 |
+
}
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
const getFileIcon = (file: FileItem) => {
|
| 194 |
+
if (file.type === 'folder') {
|
| 195 |
+
// Special icon for public folder
|
| 196 |
+
if (file.path === 'public' || file.name === 'Public Folder') {
|
| 197 |
+
return <Users size={48} weight="fill" className="text-purple-400" />
|
| 198 |
+
}
|
| 199 |
+
return <FolderIcon size={48} weight="fill" className="text-orange-400" />
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
const ext = file.extension?.toLowerCase()
|
| 203 |
+
switch (ext) {
|
| 204 |
+
case 'pdf':
|
| 205 |
+
return <FilePdf size={48} weight="fill" className="text-red-500" />
|
| 206 |
+
case 'doc':
|
| 207 |
+
case 'docx':
|
| 208 |
+
return <FileDoc size={48} weight="fill" className="text-blue-500" />
|
| 209 |
+
case 'txt':
|
| 210 |
+
case 'md':
|
| 211 |
+
return <FileText size={48} weight="fill" className="text-gray-600" />
|
| 212 |
+
case 'jpg':
|
| 213 |
+
case 'jpeg':
|
| 214 |
+
case 'png':
|
| 215 |
+
case 'gif':
|
| 216 |
+
return <ImageIcon size={48} weight="fill" className="text-green-500" />
|
| 217 |
+
default:
|
| 218 |
+
return <File size={48} weight="regular" className="text-gray-500" />
|
| 219 |
+
}
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
const formatFileSize = (bytes?: number) => {
|
| 223 |
+
if (!bytes) return ''
|
| 224 |
+
const units = ['B', 'KB', 'MB', 'GB']
|
| 225 |
+
let size = bytes
|
| 226 |
+
let unitIndex = 0
|
| 227 |
+
while (size >= 1024 && unitIndex < units.length - 1) {
|
| 228 |
+
size /= 1024
|
| 229 |
+
unitIndex++
|
| 230 |
+
}
|
| 231 |
+
return `${size.toFixed(1)} ${units[unitIndex]}`
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
const filteredFiles = files.filter(file =>
|
| 235 |
+
file.name.toLowerCase().includes(searchQuery.toLowerCase())
|
| 236 |
+
)
|
| 237 |
+
|
| 238 |
+
// Group files by current directory level
|
| 239 |
+
const currentLevelFiles = filteredFiles.filter(file => {
|
| 240 |
+
const relativePath = currentPath ? file.path.replace(currentPath + '/', '') : file.path
|
| 241 |
+
return !relativePath.includes('/')
|
| 242 |
+
})
|
| 243 |
+
|
| 244 |
+
const handleMouseDown = (e: React.MouseEvent) => {
|
| 245 |
+
if ((e.target as HTMLElement).closest('.window-controls')) return
|
| 246 |
+
setIsDragging(true)
|
| 247 |
+
setDragStart({ x: e.clientX - windowPos.x, y: e.clientY - windowPos.y })
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
const handleMouseMove = (e: MouseEvent) => {
|
| 251 |
+
if (isDragging) {
|
| 252 |
+
setWindowPos({
|
| 253 |
+
x: e.clientX - dragStart.x,
|
| 254 |
+
y: e.clientY - dragStart.y,
|
| 255 |
+
})
|
| 256 |
+
}
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
const handleMouseUp = () => {
|
| 260 |
+
setIsDragging(false)
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
useEffect(() => {
|
| 264 |
+
if (isDragging) {
|
| 265 |
+
window.addEventListener('mousemove', handleMouseMove)
|
| 266 |
+
window.addEventListener('mouseup', handleMouseUp)
|
| 267 |
+
return () => {
|
| 268 |
+
window.removeEventListener('mousemove', handleMouseMove)
|
| 269 |
+
window.removeEventListener('mouseup', handleMouseUp)
|
| 270 |
+
}
|
| 271 |
+
}
|
| 272 |
+
}, [isDragging, dragStart])
|
| 273 |
+
|
| 274 |
+
return (
|
| 275 |
+
<>
|
| 276 |
+
<motion.div
|
| 277 |
+
style={{ left: windowPos.x, top: windowPos.y }}
|
| 278 |
+
className="fixed w-[800px] h-[600px] bg-white rounded-lg shadow-2xl overflow-hidden flex flex-col z-30 select-none"
|
| 279 |
+
>
|
| 280 |
+
{/* Title Bar */}
|
| 281 |
+
<div
|
| 282 |
+
onMouseDown={handleMouseDown}
|
| 283 |
+
className="h-11 bg-gradient-to-b from-[#f6f5f4] to-[#edebe9] border-b border-[#d0d0d0] flex items-center justify-between px-3 cursor-move"
|
| 284 |
+
>
|
| 285 |
+
<div className="flex items-center gap-2 flex-1">
|
| 286 |
+
<div className="flex items-center gap-1 window-controls">
|
| 287 |
+
<button
|
| 288 |
+
onClick={onClose}
|
| 289 |
+
className="w-5 h-5 rounded-full bg-[#E95420] hover:bg-[#d14818] flex items-center justify-center group"
|
| 290 |
+
>
|
| 291 |
+
<X size={12} weight="bold" className="text-white opacity-0 group-hover:opacity-100" />
|
| 292 |
+
</button>
|
| 293 |
+
<button className="w-5 h-5 rounded-full bg-[#ddd] hover:bg-[#ccc] flex items-center justify-center group">
|
| 294 |
+
<Minus size={12} weight="bold" className="text-[#666] opacity-0 group-hover:opacity-100" />
|
| 295 |
+
</button>
|
| 296 |
+
<button className="w-5 h-5 rounded-full bg-[#ddd] hover:bg-[#ccc] flex items-center justify-center group">
|
| 297 |
+
<Square size={10} weight="bold" className="text-[#666] opacity-0 group-hover:opacity-100" />
|
| 298 |
+
</button>
|
| 299 |
+
</div>
|
| 300 |
+
<span className="text-sm font-medium text-[#2c2c2c] ml-2 flex items-center gap-2">
|
| 301 |
+
File Manager - {isPublicFolder ? (
|
| 302 |
+
<>
|
| 303 |
+
<Globe size={16} className="text-purple-500" />
|
| 304 |
+
Public Folder {currentPath.replace('public', '').replace('/', '')}
|
| 305 |
+
</>
|
| 306 |
+
) : (currentPath || 'Documents')}
|
| 307 |
+
</span>
|
| 308 |
+
</div>
|
| 309 |
+
</div>
|
| 310 |
+
|
| 311 |
+
{/* Toolbar */}
|
| 312 |
+
<div className="h-12 bg-[#fafafa] border-b border-[#e0e0e0] flex items-center px-3 gap-2">
|
| 313 |
+
<div className="flex items-center gap-1">
|
| 314 |
+
<button
|
| 315 |
+
onClick={() => {
|
| 316 |
+
const parent = currentPath.split('/').slice(0, -1).join('/')
|
| 317 |
+
onNavigate(parent)
|
| 318 |
+
}}
|
| 319 |
+
disabled={!currentPath}
|
| 320 |
+
className="p-1.5 hover:bg-[#e8e8e8] rounded disabled:opacity-40 disabled:hover:bg-transparent"
|
| 321 |
+
>
|
| 322 |
+
<CaretLeft size={16} weight="bold" />
|
| 323 |
+
</button>
|
| 324 |
+
<button
|
| 325 |
+
onClick={() => onNavigate('')}
|
| 326 |
+
className="p-1.5 hover:bg-[#e8e8e8] rounded"
|
| 327 |
+
>
|
| 328 |
+
<House size={16} weight="regular" />
|
| 329 |
+
</button>
|
| 330 |
+
</div>
|
| 331 |
+
|
| 332 |
+
<div className="flex items-center gap-1 px-2 py-1.5 bg-white border border-[#ddd] rounded flex-1">
|
| 333 |
+
<House size={14} weight="regular" className="text-[#666]" />
|
| 334 |
+
<span className="text-xs text-[#666]">/</span>
|
| 335 |
+
<span className="text-xs text-[#2c2c2c]">data/documents/{currentPath}</span>
|
| 336 |
+
</div>
|
| 337 |
+
|
| 338 |
+
<div className="flex items-center gap-1">
|
| 339 |
+
<button
|
| 340 |
+
onClick={handleCreateFolder}
|
| 341 |
+
className="p-1.5 hover:bg-[#e8e8e8] rounded"
|
| 342 |
+
title="New Folder"
|
| 343 |
+
>
|
| 344 |
+
<Plus size={16} weight="bold" />
|
| 345 |
+
</button>
|
| 346 |
+
<button
|
| 347 |
+
onClick={() => setUploadModalOpen(true)}
|
| 348 |
+
className="p-1.5 hover:bg-[#e8e8e8] rounded"
|
| 349 |
+
title="Upload File"
|
| 350 |
+
>
|
| 351 |
+
<Upload size={16} weight="regular" />
|
| 352 |
+
</button>
|
| 353 |
+
<div className="flex items-center gap-1 px-2 py-1 bg-white border border-[#ddd] rounded">
|
| 354 |
+
<MagnifyingGlass size={14} weight="regular" className="text-[#666]" />
|
| 355 |
+
<input
|
| 356 |
+
type="text"
|
| 357 |
+
placeholder="Search..."
|
| 358 |
+
value={searchQuery}
|
| 359 |
+
onChange={(e) => setSearchQuery(e.target.value)}
|
| 360 |
+
className="text-xs outline-none w-32"
|
| 361 |
+
/>
|
| 362 |
+
</div>
|
| 363 |
+
</div>
|
| 364 |
+
</div>
|
| 365 |
+
|
| 366 |
+
{/* File List */}
|
| 367 |
+
<div className="flex-1 overflow-auto p-4">
|
| 368 |
+
{loading ? (
|
| 369 |
+
<div className="flex items-center justify-center h-full text-[#999] text-sm">
|
| 370 |
+
Loading files...
|
| 371 |
+
</div>
|
| 372 |
+
) : currentLevelFiles.length === 0 ? (
|
| 373 |
+
<div className="flex items-center justify-center h-full text-[#999] text-sm">
|
| 374 |
+
{searchQuery ? 'No files found' : 'Folder is empty'}
|
| 375 |
+
</div>
|
| 376 |
+
) : (
|
| 377 |
+
<div className="grid grid-cols-6 gap-4">
|
| 378 |
+
{currentLevelFiles.map((file) => (
|
| 379 |
+
<div
|
| 380 |
+
key={file.path}
|
| 381 |
+
className="group relative"
|
| 382 |
+
>
|
| 383 |
+
<button
|
| 384 |
+
onClick={() => {
|
| 385 |
+
if (file.type === 'folder') {
|
| 386 |
+
onNavigate(file.path)
|
| 387 |
+
} else {
|
| 388 |
+
handlePreview(file)
|
| 389 |
+
}
|
| 390 |
+
}}
|
| 391 |
+
className="flex flex-col items-center gap-2 p-2 hover:bg-[#f0f0f0] rounded w-full"
|
| 392 |
+
>
|
| 393 |
+
<div className="w-16 h-16 flex items-center justify-center">
|
| 394 |
+
{getFileIcon(file)}
|
| 395 |
+
</div>
|
| 396 |
+
<span className="text-xs text-[#2c2c2c] text-center break-all w-full line-clamp-2">
|
| 397 |
+
{file.name}
|
| 398 |
+
</span>
|
| 399 |
+
{file.size && (
|
| 400 |
+
<span className="text-xs text-[#666]">
|
| 401 |
+
{formatFileSize(file.size)}
|
| 402 |
+
</span>
|
| 403 |
+
)}
|
| 404 |
+
</button>
|
| 405 |
+
|
| 406 |
+
{/* File Actions */}
|
| 407 |
+
{file.type === 'file' && (
|
| 408 |
+
<div className="absolute top-1 right-1 hidden group-hover:flex gap-1">
|
| 409 |
+
<button
|
| 410 |
+
onClick={(e) => {
|
| 411 |
+
e.stopPropagation()
|
| 412 |
+
handlePreview(file)
|
| 413 |
+
}}
|
| 414 |
+
className="p-1 bg-white rounded shadow hover:bg-[#f0f0f0]"
|
| 415 |
+
title="Preview"
|
| 416 |
+
>
|
| 417 |
+
<Eye size={14} />
|
| 418 |
+
</button>
|
| 419 |
+
<button
|
| 420 |
+
onClick={(e) => {
|
| 421 |
+
e.stopPropagation()
|
| 422 |
+
handleDownload(file)
|
| 423 |
+
}}
|
| 424 |
+
className="p-1 bg-white rounded shadow hover:bg-[#f0f0f0]"
|
| 425 |
+
title="Download"
|
| 426 |
+
>
|
| 427 |
+
<Download size={14} />
|
| 428 |
+
</button>
|
| 429 |
+
<button
|
| 430 |
+
onClick={(e) => {
|
| 431 |
+
e.stopPropagation()
|
| 432 |
+
handleDelete(file)
|
| 433 |
+
}}
|
| 434 |
+
className="p-1 bg-white rounded shadow hover:bg-red-100"
|
| 435 |
+
title="Delete"
|
| 436 |
+
>
|
| 437 |
+
<Trash size={14} className="text-red-600" />
|
| 438 |
+
</button>
|
| 439 |
+
</div>
|
| 440 |
+
)}
|
| 441 |
+
</div>
|
| 442 |
+
))}
|
| 443 |
+
</div>
|
| 444 |
+
)}
|
| 445 |
+
</div>
|
| 446 |
+
|
| 447 |
+
{/* Status Bar */}
|
| 448 |
+
<div className="h-6 bg-[#f6f5f4] border-t border-[#d0d0d0] flex items-center px-3 text-xs text-[#666]">
|
| 449 |
+
{currentLevelFiles.length} items • {files.filter(f => f.type === 'folder').length} folders • {files.filter(f => f.type === 'file').length} files
|
| 450 |
+
</div>
|
| 451 |
+
</motion.div>
|
| 452 |
+
|
| 453 |
+
{/* Upload Modal */}
|
| 454 |
+
{uploadModalOpen && (
|
| 455 |
+
<UploadModal
|
| 456 |
+
currentPath={currentPath}
|
| 457 |
+
onUpload={handleUpload}
|
| 458 |
+
onClose={() => setUploadModalOpen(false)}
|
| 459 |
+
/>
|
| 460 |
+
)}
|
| 461 |
+
|
| 462 |
+
{/* File Preview Modal */}
|
| 463 |
+
{previewFile && (
|
| 464 |
+
<FilePreview
|
| 465 |
+
file={previewFile}
|
| 466 |
+
onClose={() => setPreviewFile(null)}
|
| 467 |
+
onDownload={() => handleDownload(previewFile)}
|
| 468 |
+
/>
|
| 469 |
+
)}
|
| 470 |
+
</>
|
| 471 |
+
)
|
| 472 |
+
}
|
| 473 |
+
|
| 474 |
+
// Upload Modal Component
|
| 475 |
+
function UploadModal({
|
| 476 |
+
currentPath,
|
| 477 |
+
onUpload,
|
| 478 |
+
onClose
|
| 479 |
+
}: {
|
| 480 |
+
currentPath: string
|
| 481 |
+
onUpload: (file: File, folder: string) => void
|
| 482 |
+
onClose: () => void
|
| 483 |
+
}) {
|
| 484 |
+
const [selectedFile, setSelectedFile] = useState<File | null>(null)
|
| 485 |
+
const [targetFolder, setTargetFolder] = useState(currentPath)
|
| 486 |
+
|
| 487 |
+
const handleSubmit = () => {
|
| 488 |
+
if (selectedFile) {
|
| 489 |
+
onUpload(selectedFile, targetFolder)
|
| 490 |
+
}
|
| 491 |
+
}
|
| 492 |
+
|
| 493 |
+
return (
|
| 494 |
+
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
| 495 |
+
<div className="bg-white rounded-lg p-6 w-[400px]">
|
| 496 |
+
<h2 className="text-lg font-semibold mb-4">Upload File</h2>
|
| 497 |
+
|
| 498 |
+
<div className="space-y-4">
|
| 499 |
+
<div>
|
| 500 |
+
<label className="block text-sm font-medium mb-2">Select File</label>
|
| 501 |
+
<input
|
| 502 |
+
type="file"
|
| 503 |
+
onChange={(e) => setSelectedFile(e.target.files?.[0] || null)}
|
| 504 |
+
className="w-full p-2 border rounded"
|
| 505 |
+
/>
|
| 506 |
+
</div>
|
| 507 |
+
|
| 508 |
+
<div>
|
| 509 |
+
<label className="block text-sm font-medium mb-2">Upload to Folder</label>
|
| 510 |
+
<input
|
| 511 |
+
type="text"
|
| 512 |
+
value={targetFolder}
|
| 513 |
+
onChange={(e) => setTargetFolder(e.target.value)}
|
| 514 |
+
placeholder="e.g., homework/math"
|
| 515 |
+
className="w-full p-2 border rounded"
|
| 516 |
+
/>
|
| 517 |
+
<p className="text-xs text-gray-500 mt-1">
|
| 518 |
+
Leave empty for root, or enter path like "folder/subfolder"
|
| 519 |
+
</p>
|
| 520 |
+
</div>
|
| 521 |
+
|
| 522 |
+
<div className="flex justify-end gap-2">
|
| 523 |
+
<button
|
| 524 |
+
onClick={onClose}
|
| 525 |
+
className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded"
|
| 526 |
+
>
|
| 527 |
+
Cancel
|
| 528 |
+
</button>
|
| 529 |
+
<button
|
| 530 |
+
onClick={handleSubmit}
|
| 531 |
+
disabled={!selectedFile}
|
| 532 |
+
className="px-4 py-2 bg-[#E95420] text-white rounded hover:bg-[#d14818] disabled:opacity-50"
|
| 533 |
+
>
|
| 534 |
+
Upload
|
| 535 |
+
</button>
|
| 536 |
+
</div>
|
| 537 |
+
</div>
|
| 538 |
+
</div>
|
| 539 |
+
</div>
|
| 540 |
+
)
|
| 541 |
+
}
|
| 542 |
+
|
app/components/FilePreview.tsx
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import React, { useState, useEffect } from 'react'
|
| 4 |
+
import { X, Download, MagnifyingGlassPlus, MagnifyingGlassMinus } from '@phosphor-icons/react'
|
| 5 |
+
import mammoth from 'mammoth'
|
| 6 |
+
import * as XLSX from 'xlsx'
|
| 7 |
+
import { PDFViewer } from './PDFViewer'
|
| 8 |
+
|
| 9 |
+
interface FilePreviewProps {
|
| 10 |
+
file: {
|
| 11 |
+
name: string
|
| 12 |
+
path: string
|
| 13 |
+
extension?: string
|
| 14 |
+
size?: number
|
| 15 |
+
}
|
| 16 |
+
onClose: () => void
|
| 17 |
+
onDownload: () => void
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
export function FilePreview({ file, onClose, onDownload }: FilePreviewProps) {
|
| 21 |
+
const [content, setContent] = useState<any>(null)
|
| 22 |
+
const [loading, setLoading] = useState(true)
|
| 23 |
+
const [error, setError] = useState<string | null>(null)
|
| 24 |
+
const [scale, setScale] = useState(1)
|
| 25 |
+
|
| 26 |
+
// Excel specific state
|
| 27 |
+
const [activeSheet, setActiveSheet] = useState(0)
|
| 28 |
+
|
| 29 |
+
const previewUrl = `/api/download?path=${encodeURIComponent(file.path)}&preview=true`
|
| 30 |
+
const ext = file.extension?.toLowerCase() || ''
|
| 31 |
+
|
| 32 |
+
useEffect(() => {
|
| 33 |
+
loadFileContent()
|
| 34 |
+
}, [file])
|
| 35 |
+
|
| 36 |
+
const loadFileContent = async () => {
|
| 37 |
+
setLoading(true)
|
| 38 |
+
setError(null)
|
| 39 |
+
|
| 40 |
+
try {
|
| 41 |
+
switch (ext) {
|
| 42 |
+
case 'docx':
|
| 43 |
+
case 'doc':
|
| 44 |
+
await loadWordDocument()
|
| 45 |
+
break
|
| 46 |
+
case 'xlsx':
|
| 47 |
+
case 'xls':
|
| 48 |
+
await loadExcelDocument()
|
| 49 |
+
break
|
| 50 |
+
case 'pptx':
|
| 51 |
+
case 'ppt':
|
| 52 |
+
await loadPowerPointDocument()
|
| 53 |
+
break
|
| 54 |
+
case 'txt':
|
| 55 |
+
case 'md':
|
| 56 |
+
case 'json':
|
| 57 |
+
case 'js':
|
| 58 |
+
case 'ts':
|
| 59 |
+
case 'jsx':
|
| 60 |
+
case 'tsx':
|
| 61 |
+
case 'css':
|
| 62 |
+
case 'html':
|
| 63 |
+
case 'xml':
|
| 64 |
+
case 'py':
|
| 65 |
+
case 'java':
|
| 66 |
+
case 'cpp':
|
| 67 |
+
case 'c':
|
| 68 |
+
case 'h':
|
| 69 |
+
case 'sh':
|
| 70 |
+
case 'yaml':
|
| 71 |
+
case 'yml':
|
| 72 |
+
await loadTextFile()
|
| 73 |
+
break
|
| 74 |
+
case 'csv':
|
| 75 |
+
await loadCSVFile()
|
| 76 |
+
break
|
| 77 |
+
default:
|
| 78 |
+
setContent(null)
|
| 79 |
+
}
|
| 80 |
+
} catch (err) {
|
| 81 |
+
setError(err instanceof Error ? err.message : 'Failed to load file')
|
| 82 |
+
} finally {
|
| 83 |
+
setLoading(false)
|
| 84 |
+
}
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
const loadWordDocument = async () => {
|
| 88 |
+
try {
|
| 89 |
+
const response = await fetch(previewUrl)
|
| 90 |
+
const arrayBuffer = await response.arrayBuffer()
|
| 91 |
+
const result = await mammoth.convertToHtml({ arrayBuffer })
|
| 92 |
+
setContent({ type: 'html', data: result.value })
|
| 93 |
+
} catch (err) {
|
| 94 |
+
throw new Error('Failed to load Word document')
|
| 95 |
+
}
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
const loadExcelDocument = async () => {
|
| 99 |
+
try {
|
| 100 |
+
const response = await fetch(previewUrl)
|
| 101 |
+
const arrayBuffer = await response.arrayBuffer()
|
| 102 |
+
const workbook = XLSX.read(arrayBuffer, { type: 'array' })
|
| 103 |
+
|
| 104 |
+
// Convert all sheets to HTML
|
| 105 |
+
const sheets = workbook.SheetNames.map(name => ({
|
| 106 |
+
name,
|
| 107 |
+
html: XLSX.utils.sheet_to_html(workbook.Sheets[name], {
|
| 108 |
+
header: '<table class="excel-table">',
|
| 109 |
+
footer: '</table>'
|
| 110 |
+
})
|
| 111 |
+
}))
|
| 112 |
+
|
| 113 |
+
setContent({ type: 'excel', data: sheets })
|
| 114 |
+
} catch (err) {
|
| 115 |
+
throw new Error('Failed to load Excel document')
|
| 116 |
+
}
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
const loadPowerPointDocument = async () => {
|
| 120 |
+
// For PowerPoint, we'll show a message to download
|
| 121 |
+
// Full PowerPoint rendering requires more complex libraries
|
| 122 |
+
setContent({ type: 'powerpoint', data: null })
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
const loadTextFile = async () => {
|
| 126 |
+
try {
|
| 127 |
+
const response = await fetch(previewUrl)
|
| 128 |
+
const text = await response.text()
|
| 129 |
+
setContent({ type: 'text', data: text })
|
| 130 |
+
} catch (err) {
|
| 131 |
+
throw new Error('Failed to load text file')
|
| 132 |
+
}
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
const loadCSVFile = async () => {
|
| 136 |
+
try {
|
| 137 |
+
const response = await fetch(previewUrl)
|
| 138 |
+
const text = await response.text()
|
| 139 |
+
const workbook = XLSX.read(text, { type: 'string' })
|
| 140 |
+
const sheet = workbook.Sheets[workbook.SheetNames[0]]
|
| 141 |
+
const html = XLSX.utils.sheet_to_html(sheet, {
|
| 142 |
+
header: '<table class="csv-table">',
|
| 143 |
+
footer: '</table>'
|
| 144 |
+
})
|
| 145 |
+
setContent({ type: 'html', data: html })
|
| 146 |
+
} catch (err) {
|
| 147 |
+
throw new Error('Failed to load CSV file')
|
| 148 |
+
}
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
const renderContent = () => {
|
| 153 |
+
if (loading) {
|
| 154 |
+
return (
|
| 155 |
+
<div className="flex items-center justify-center h-full">
|
| 156 |
+
<div className="text-gray-600">Loading...</div>
|
| 157 |
+
</div>
|
| 158 |
+
)
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
if (error) {
|
| 162 |
+
return (
|
| 163 |
+
<div className="flex flex-col items-center justify-center h-full">
|
| 164 |
+
<p className="text-red-600 mb-4">{error}</p>
|
| 165 |
+
<button
|
| 166 |
+
onClick={onDownload}
|
| 167 |
+
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
|
| 168 |
+
>
|
| 169 |
+
Download to View
|
| 170 |
+
</button>
|
| 171 |
+
</div>
|
| 172 |
+
)
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
// PDF files
|
| 176 |
+
if (ext === 'pdf') {
|
| 177 |
+
return (
|
| 178 |
+
<PDFViewer
|
| 179 |
+
url={previewUrl}
|
| 180 |
+
scale={scale}
|
| 181 |
+
onZoomIn={() => setScale(prev => Math.min(2, prev + 0.1))}
|
| 182 |
+
onZoomOut={() => setScale(prev => Math.max(0.5, prev - 0.1))}
|
| 183 |
+
/>
|
| 184 |
+
)
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
// Image files
|
| 188 |
+
if (['jpg', 'jpeg', 'png', 'gif', 'svg', 'webp', 'bmp'].includes(ext)) {
|
| 189 |
+
return (
|
| 190 |
+
<div className="flex items-center justify-center h-full p-4">
|
| 191 |
+
<img
|
| 192 |
+
src={previewUrl}
|
| 193 |
+
alt={file.name}
|
| 194 |
+
className="max-w-full max-h-full object-contain"
|
| 195 |
+
style={{ transform: `scale(${scale})` }}
|
| 196 |
+
/>
|
| 197 |
+
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 flex items-center gap-2 bg-white rounded-lg shadow-lg p-2">
|
| 198 |
+
<button
|
| 199 |
+
onClick={() => setScale(prev => Math.max(0.5, prev - 0.1))}
|
| 200 |
+
className="p-1 rounded hover:bg-gray-100"
|
| 201 |
+
>
|
| 202 |
+
<MagnifyingGlassMinus size={16} />
|
| 203 |
+
</button>
|
| 204 |
+
<span className="text-sm px-2">{Math.round(scale * 100)}%</span>
|
| 205 |
+
<button
|
| 206 |
+
onClick={() => setScale(prev => Math.min(3, prev + 0.1))}
|
| 207 |
+
className="p-1 rounded hover:bg-gray-100"
|
| 208 |
+
>
|
| 209 |
+
<MagnifyingGlassPlus size={16} />
|
| 210 |
+
</button>
|
| 211 |
+
</div>
|
| 212 |
+
</div>
|
| 213 |
+
)
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
// HTML content (Word docs, converted HTML)
|
| 217 |
+
if (content?.type === 'html') {
|
| 218 |
+
return (
|
| 219 |
+
<div className="p-4 overflow-auto h-full">
|
| 220 |
+
<div
|
| 221 |
+
dangerouslySetInnerHTML={{ __html: content.data }}
|
| 222 |
+
className="prose max-w-none"
|
| 223 |
+
/>
|
| 224 |
+
</div>
|
| 225 |
+
)
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
// Excel sheets
|
| 229 |
+
if (content?.type === 'excel') {
|
| 230 |
+
return (
|
| 231 |
+
<div className="flex flex-col h-full">
|
| 232 |
+
<div className="flex gap-2 p-2 border-b">
|
| 233 |
+
{content.data.map((sheet: any, index: number) => (
|
| 234 |
+
<button
|
| 235 |
+
key={index}
|
| 236 |
+
onClick={() => setActiveSheet(index)}
|
| 237 |
+
className={`px-3 py-1 rounded ${
|
| 238 |
+
activeSheet === index ? 'bg-blue-500 text-white' : 'bg-gray-100'
|
| 239 |
+
}`}
|
| 240 |
+
>
|
| 241 |
+
{sheet.name}
|
| 242 |
+
</button>
|
| 243 |
+
))}
|
| 244 |
+
</div>
|
| 245 |
+
<div className="flex-1 overflow-auto p-4">
|
| 246 |
+
<div
|
| 247 |
+
dangerouslySetInnerHTML={{ __html: content.data[activeSheet].html }}
|
| 248 |
+
className="excel-preview"
|
| 249 |
+
/>
|
| 250 |
+
</div>
|
| 251 |
+
</div>
|
| 252 |
+
)
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
// Text files
|
| 256 |
+
if (content?.type === 'text') {
|
| 257 |
+
return (
|
| 258 |
+
<div className="h-full overflow-auto">
|
| 259 |
+
<pre className="p-4 text-sm font-mono whitespace-pre-wrap">
|
| 260 |
+
{content.data}
|
| 261 |
+
</pre>
|
| 262 |
+
</div>
|
| 263 |
+
)
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
// PowerPoint placeholder
|
| 267 |
+
if (content?.type === 'powerpoint') {
|
| 268 |
+
return (
|
| 269 |
+
<div className="flex flex-col items-center justify-center h-full">
|
| 270 |
+
<div className="text-6xl mb-4">📊</div>
|
| 271 |
+
<p className="text-gray-600 mb-4">PowerPoint presentation</p>
|
| 272 |
+
<p className="text-sm text-gray-500 mb-4">Preview requires download</p>
|
| 273 |
+
<button
|
| 274 |
+
onClick={onDownload}
|
| 275 |
+
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
|
| 276 |
+
>
|
| 277 |
+
Download to View
|
| 278 |
+
</button>
|
| 279 |
+
</div>
|
| 280 |
+
)
|
| 281 |
+
}
|
| 282 |
+
|
| 283 |
+
// Default fallback
|
| 284 |
+
return (
|
| 285 |
+
<iframe
|
| 286 |
+
src={previewUrl}
|
| 287 |
+
className="w-full h-full border-0"
|
| 288 |
+
title={file.name}
|
| 289 |
+
/>
|
| 290 |
+
)
|
| 291 |
+
}
|
| 292 |
+
|
| 293 |
+
return (
|
| 294 |
+
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
| 295 |
+
<div className="bg-white rounded-lg w-[90%] h-[90%] flex flex-col">
|
| 296 |
+
<div className="flex items-center justify-between p-4 border-b">
|
| 297 |
+
<div>
|
| 298 |
+
<h2 className="text-lg font-semibold">{file.name}</h2>
|
| 299 |
+
<p className="text-sm text-gray-500">
|
| 300 |
+
{ext.toUpperCase()} • {file.size ? formatFileSize(file.size) : 'Unknown size'}
|
| 301 |
+
</p>
|
| 302 |
+
</div>
|
| 303 |
+
<div className="flex gap-2">
|
| 304 |
+
<button
|
| 305 |
+
onClick={onDownload}
|
| 306 |
+
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 flex items-center gap-2"
|
| 307 |
+
>
|
| 308 |
+
<Download size={20} />
|
| 309 |
+
Download
|
| 310 |
+
</button>
|
| 311 |
+
<button
|
| 312 |
+
onClick={onClose}
|
| 313 |
+
className="p-2 hover:bg-gray-100 rounded"
|
| 314 |
+
>
|
| 315 |
+
<X size={20} />
|
| 316 |
+
</button>
|
| 317 |
+
</div>
|
| 318 |
+
</div>
|
| 319 |
+
|
| 320 |
+
<div className="flex-1 overflow-hidden relative">
|
| 321 |
+
{renderContent()}
|
| 322 |
+
</div>
|
| 323 |
+
</div>
|
| 324 |
+
|
| 325 |
+
<style jsx global>{`
|
| 326 |
+
.excel-table, .csv-table {
|
| 327 |
+
border-collapse: collapse;
|
| 328 |
+
width: 100%;
|
| 329 |
+
}
|
| 330 |
+
.excel-table td, .excel-table th,
|
| 331 |
+
.csv-table td, .csv-table th {
|
| 332 |
+
border: 1px solid #ddd;
|
| 333 |
+
padding: 8px;
|
| 334 |
+
text-align: left;
|
| 335 |
+
}
|
| 336 |
+
.excel-table th, .csv-table th {
|
| 337 |
+
background-color: #f2f2f2;
|
| 338 |
+
font-weight: bold;
|
| 339 |
+
}
|
| 340 |
+
.excel-table tr:nth-child(even),
|
| 341 |
+
.csv-table tr:nth-child(even) {
|
| 342 |
+
background-color: #f9f9f9;
|
| 343 |
+
}
|
| 344 |
+
.excel-preview {
|
| 345 |
+
font-family: 'Arial', sans-serif;
|
| 346 |
+
}
|
| 347 |
+
.prose {
|
| 348 |
+
max-width: none;
|
| 349 |
+
}
|
| 350 |
+
.prose h1 { font-size: 2em; font-weight: bold; margin: 1em 0 0.5em; }
|
| 351 |
+
.prose h2 { font-size: 1.5em; font-weight: bold; margin: 1em 0 0.5em; }
|
| 352 |
+
.prose h3 { font-size: 1.2em; font-weight: bold; margin: 1em 0 0.5em; }
|
| 353 |
+
.prose p { margin: 1em 0; }
|
| 354 |
+
.prose ul, .prose ol { margin: 1em 0; padding-left: 2em; }
|
| 355 |
+
.prose li { margin: 0.5em 0; }
|
| 356 |
+
.prose strong { font-weight: bold; }
|
| 357 |
+
.prose em { font-style: italic; }
|
| 358 |
+
`}</style>
|
| 359 |
+
</div>
|
| 360 |
+
)
|
| 361 |
+
}
|
| 362 |
+
|
| 363 |
+
function formatFileSize(bytes: number): string {
|
| 364 |
+
const units = ['B', 'KB', 'MB', 'GB']
|
| 365 |
+
let size = bytes
|
| 366 |
+
let unitIndex = 0
|
| 367 |
+
while (size >= 1024 && unitIndex < units.length - 1) {
|
| 368 |
+
size /= 1024
|
| 369 |
+
unitIndex++
|
| 370 |
+
}
|
| 371 |
+
return `${size.toFixed(1)} ${units[unitIndex]}`
|
| 372 |
+
}
|
app/components/GeminiChat.tsx
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import React, { useState, useRef, useEffect } from 'react'
|
| 4 |
+
import Window from './Window'
|
| 5 |
+
import {
|
| 6 |
+
PaperPlaneRight,
|
| 7 |
+
Sparkle,
|
| 8 |
+
ArrowUp
|
| 9 |
+
} from '@phosphor-icons/react'
|
| 10 |
+
|
| 11 |
+
interface GeminiChatProps {
|
| 12 |
+
onClose: () => void
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
interface Message {
|
| 16 |
+
id: string
|
| 17 |
+
role: 'user' | 'assistant'
|
| 18 |
+
content: string
|
| 19 |
+
timestamp: number
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
export function GeminiChat({ onClose }: GeminiChatProps) {
|
| 23 |
+
const [messages, setMessages] = useState<Message[]>([
|
| 24 |
+
{
|
| 25 |
+
id: '1',
|
| 26 |
+
role: 'assistant',
|
| 27 |
+
content: "Hello! I'm Gemini. How can I help you today?",
|
| 28 |
+
timestamp: Date.now()
|
| 29 |
+
}
|
| 30 |
+
])
|
| 31 |
+
const [input, setInput] = useState('')
|
| 32 |
+
const [isLoading, setIsLoading] = useState(false)
|
| 33 |
+
const scrollRef = useRef<HTMLDivElement>(null)
|
| 34 |
+
const inputRef = useRef<HTMLInputElement>(null)
|
| 35 |
+
|
| 36 |
+
useEffect(() => {
|
| 37 |
+
// Load messages from localStorage
|
| 38 |
+
const savedMessages = localStorage.getItem('gemini-chat-messages')
|
| 39 |
+
if (savedMessages) {
|
| 40 |
+
try {
|
| 41 |
+
const parsed = JSON.parse(savedMessages)
|
| 42 |
+
if (parsed.length > 0) {
|
| 43 |
+
setMessages(parsed)
|
| 44 |
+
}
|
| 45 |
+
} catch (e) {
|
| 46 |
+
console.error('Failed to load messages')
|
| 47 |
+
}
|
| 48 |
+
}
|
| 49 |
+
}, [])
|
| 50 |
+
|
| 51 |
+
useEffect(() => {
|
| 52 |
+
// Save messages to localStorage
|
| 53 |
+
if (messages.length > 1) {
|
| 54 |
+
localStorage.setItem('gemini-chat-messages', JSON.stringify(messages.slice(-20))) // Keep last 20 messages
|
| 55 |
+
}
|
| 56 |
+
}, [messages])
|
| 57 |
+
|
| 58 |
+
useEffect(() => {
|
| 59 |
+
if (scrollRef.current) {
|
| 60 |
+
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
|
| 61 |
+
}
|
| 62 |
+
}, [messages])
|
| 63 |
+
|
| 64 |
+
const handleSend = async () => {
|
| 65 |
+
if (!input.trim() || isLoading) return
|
| 66 |
+
|
| 67 |
+
const userMessage: Message = {
|
| 68 |
+
id: Date.now().toString(),
|
| 69 |
+
role: 'user',
|
| 70 |
+
content: input.trim(),
|
| 71 |
+
timestamp: Date.now()
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
setMessages(prev => [...prev, userMessage])
|
| 75 |
+
setInput('')
|
| 76 |
+
setIsLoading(true)
|
| 77 |
+
|
| 78 |
+
try {
|
| 79 |
+
// Get conversation history (last 10 messages)
|
| 80 |
+
const history = messages.slice(-10).map(msg => ({
|
| 81 |
+
role: msg.role,
|
| 82 |
+
content: msg.content
|
| 83 |
+
}))
|
| 84 |
+
|
| 85 |
+
const response = await fetch('/api/gemini/chat', {
|
| 86 |
+
method: 'POST',
|
| 87 |
+
headers: {
|
| 88 |
+
'Content-Type': 'application/json',
|
| 89 |
+
},
|
| 90 |
+
body: JSON.stringify({
|
| 91 |
+
message: userMessage.content,
|
| 92 |
+
history
|
| 93 |
+
}),
|
| 94 |
+
})
|
| 95 |
+
|
| 96 |
+
const data = await response.json()
|
| 97 |
+
|
| 98 |
+
if (response.ok && data.response) {
|
| 99 |
+
const assistantMessage: Message = {
|
| 100 |
+
id: (Date.now() + 1).toString(),
|
| 101 |
+
role: 'assistant',
|
| 102 |
+
content: data.response,
|
| 103 |
+
timestamp: Date.now()
|
| 104 |
+
}
|
| 105 |
+
setMessages(prev => [...prev, assistantMessage])
|
| 106 |
+
} else {
|
| 107 |
+
// Fallback response if API fails
|
| 108 |
+
const fallbackResponses = [
|
| 109 |
+
"I'm here to help! What would you like to know?",
|
| 110 |
+
"That's interesting! Tell me more about it.",
|
| 111 |
+
"I can help with that! Let me think about the best approach.",
|
| 112 |
+
"Great question! Here's what I think...",
|
| 113 |
+
"I understand. Let me help you with that."
|
| 114 |
+
]
|
| 115 |
+
const randomResponse = fallbackResponses[Math.floor(Math.random() * fallbackResponses.length)]
|
| 116 |
+
|
| 117 |
+
const assistantMessage: Message = {
|
| 118 |
+
id: (Date.now() + 1).toString(),
|
| 119 |
+
role: 'assistant',
|
| 120 |
+
content: randomResponse,
|
| 121 |
+
timestamp: Date.now()
|
| 122 |
+
}
|
| 123 |
+
setMessages(prev => [...prev, assistantMessage])
|
| 124 |
+
}
|
| 125 |
+
} catch (error) {
|
| 126 |
+
console.error('Error sending message:', error)
|
| 127 |
+
const errorMessage: Message = {
|
| 128 |
+
id: (Date.now() + 1).toString(),
|
| 129 |
+
role: 'assistant',
|
| 130 |
+
content: "I'm currently running in demo mode. For full functionality, please ensure the API is configured.",
|
| 131 |
+
timestamp: Date.now()
|
| 132 |
+
}
|
| 133 |
+
setMessages(prev => [...prev, errorMessage])
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
setIsLoading(false)
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
const handleKeyPress = (e: React.KeyboardEvent) => {
|
| 140 |
+
if (e.key === 'Enter' && !e.shiftKey) {
|
| 141 |
+
e.preventDefault()
|
| 142 |
+
handleSend()
|
| 143 |
+
}
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
const clearChat = () => {
|
| 147 |
+
setMessages([
|
| 148 |
+
{
|
| 149 |
+
id: Date.now().toString(),
|
| 150 |
+
role: 'assistant',
|
| 151 |
+
content: "Chat history cleared. How can I help you today?",
|
| 152 |
+
timestamp: Date.now()
|
| 153 |
+
}
|
| 154 |
+
])
|
| 155 |
+
localStorage.removeItem('gemini-chat-messages')
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
return (
|
| 159 |
+
<Window
|
| 160 |
+
id="gemini"
|
| 161 |
+
title="Gemini Ultra"
|
| 162 |
+
isOpen={true}
|
| 163 |
+
onClose={onClose}
|
| 164 |
+
width={700}
|
| 165 |
+
height={500}
|
| 166 |
+
x={100}
|
| 167 |
+
y={100}
|
| 168 |
+
className="gemini-window"
|
| 169 |
+
headerClassName="bg-white border-b border-gray-100"
|
| 170 |
+
>
|
| 171 |
+
<div className="flex flex-col h-full bg-white">
|
| 172 |
+
{/* Chat Header */}
|
| 173 |
+
<div className="px-4 py-2 border-b border-gray-100 flex items-center justify-between">
|
| 174 |
+
<div className="flex items-center gap-2">
|
| 175 |
+
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-blue-500 to-pink-600 flex items-center justify-center">
|
| 176 |
+
<Sparkle size={18} weight="fill" className="text-white" />
|
| 177 |
+
</div>
|
| 178 |
+
<span className="text-transparent bg-clip-text bg-gradient-to-r from-blue-500 to-pink-600 font-bold">
|
| 179 |
+
Gemini Ultra
|
| 180 |
+
</span>
|
| 181 |
+
</div>
|
| 182 |
+
<button
|
| 183 |
+
onClick={clearChat}
|
| 184 |
+
className="text-xs text-gray-500 hover:text-gray-700"
|
| 185 |
+
>
|
| 186 |
+
Clear chat
|
| 187 |
+
</button>
|
| 188 |
+
</div>
|
| 189 |
+
|
| 190 |
+
{/* Messages Area */}
|
| 191 |
+
<div
|
| 192 |
+
ref={scrollRef}
|
| 193 |
+
className="flex-1 overflow-y-auto p-4 space-y-4"
|
| 194 |
+
>
|
| 195 |
+
{messages.map(message => (
|
| 196 |
+
<div
|
| 197 |
+
key={message.id}
|
| 198 |
+
className={`flex ${message.role === 'user' ? 'justify-end' : 'justify-start'}`}
|
| 199 |
+
>
|
| 200 |
+
<div
|
| 201 |
+
className={`max-w-[80%] ${
|
| 202 |
+
message.role === 'user'
|
| 203 |
+
? 'bg-blue-600 text-white rounded-2xl rounded-tr-none'
|
| 204 |
+
: 'bg-gray-100 text-gray-800 rounded-2xl rounded-tl-none'
|
| 205 |
+
} px-4 py-2 text-sm`}
|
| 206 |
+
>
|
| 207 |
+
{message.role === 'assistant' && (
|
| 208 |
+
<p className="font-semibold text-blue-600 mb-1 text-xs">Gemini</p>
|
| 209 |
+
)}
|
| 210 |
+
<p className="whitespace-pre-wrap">{message.content}</p>
|
| 211 |
+
</div>
|
| 212 |
+
</div>
|
| 213 |
+
))}
|
| 214 |
+
|
| 215 |
+
{isLoading && (
|
| 216 |
+
<div className="flex justify-start">
|
| 217 |
+
<div className="bg-gray-100 rounded-2xl rounded-tl-none px-4 py-3 text-sm">
|
| 218 |
+
<p className="font-semibold text-blue-600 mb-1 text-xs">Gemini</p>
|
| 219 |
+
<div className="flex gap-1">
|
| 220 |
+
<span className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '0ms' }}></span>
|
| 221 |
+
<span className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '150ms' }}></span>
|
| 222 |
+
<span className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '300ms' }}></span>
|
| 223 |
+
</div>
|
| 224 |
+
</div>
|
| 225 |
+
</div>
|
| 226 |
+
)}
|
| 227 |
+
</div>
|
| 228 |
+
|
| 229 |
+
{/* Input Area */}
|
| 230 |
+
<div className="border-t border-gray-100 p-4">
|
| 231 |
+
<div className="flex items-center gap-2">
|
| 232 |
+
<input
|
| 233 |
+
ref={inputRef}
|
| 234 |
+
type="text"
|
| 235 |
+
value={input}
|
| 236 |
+
onChange={(e) => setInput(e.target.value)}
|
| 237 |
+
onKeyDown={handleKeyPress}
|
| 238 |
+
placeholder="Ask Gemini..."
|
| 239 |
+
className="flex-1 bg-gray-100 rounded-full px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-200 transition-all"
|
| 240 |
+
disabled={isLoading}
|
| 241 |
+
/>
|
| 242 |
+
<button
|
| 243 |
+
onClick={handleSend}
|
| 244 |
+
disabled={!input.trim() || isLoading}
|
| 245 |
+
className={`w-8 h-8 rounded-full flex items-center justify-center transition-colors ${
|
| 246 |
+
input.trim() && !isLoading
|
| 247 |
+
? 'bg-blue-600 hover:bg-blue-700 text-white'
|
| 248 |
+
: 'bg-gray-200 text-gray-400 cursor-not-allowed'
|
| 249 |
+
}`}
|
| 250 |
+
>
|
| 251 |
+
<ArrowUp size={16} weight="bold" />
|
| 252 |
+
</button>
|
| 253 |
+
</div>
|
| 254 |
+
</div>
|
| 255 |
+
</div>
|
| 256 |
+
</Window>
|
| 257 |
+
)
|
| 258 |
+
}
|
app/components/HelpModal.tsx
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import React, { useState } from 'react'
|
| 4 |
+
import { motion, AnimatePresence } from 'framer-motion'
|
| 5 |
+
import { X, Minus, Square, Info, UserCircle, Target } from '@phosphor-icons/react'
|
| 6 |
+
|
| 7 |
+
interface HelpModalProps {
|
| 8 |
+
isOpen: boolean
|
| 9 |
+
onClose: () => void
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
export function HelpModal({ isOpen, onClose }: HelpModalProps) {
|
| 13 |
+
const [windowPos, setWindowPos] = useState({ x: 100, y: 100 })
|
| 14 |
+
const [isDragging, setIsDragging] = useState(false)
|
| 15 |
+
const [dragStart, setDragStart] = useState({ x: 0, y: 0 })
|
| 16 |
+
|
| 17 |
+
const handleMouseDown = (e: React.MouseEvent) => {
|
| 18 |
+
if ((e.target as HTMLElement).closest('.window-controls')) return
|
| 19 |
+
setIsDragging(true)
|
| 20 |
+
setDragStart({ x: e.clientX - windowPos.x, y: e.clientY - windowPos.y })
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
const handleMouseMove = (e: MouseEvent) => {
|
| 24 |
+
if (isDragging) {
|
| 25 |
+
setWindowPos({
|
| 26 |
+
x: e.clientX - dragStart.x,
|
| 27 |
+
y: e.clientY - dragStart.y,
|
| 28 |
+
})
|
| 29 |
+
}
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
const handleMouseUp = () => {
|
| 33 |
+
setIsDragging(false)
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
React.useEffect(() => {
|
| 37 |
+
if (isDragging) {
|
| 38 |
+
window.addEventListener('mousemove', handleMouseMove)
|
| 39 |
+
window.addEventListener('mouseup', handleMouseUp)
|
| 40 |
+
return () => {
|
| 41 |
+
window.removeEventListener('mousemove', handleMouseMove)
|
| 42 |
+
window.removeEventListener('mouseup', handleMouseUp)
|
| 43 |
+
}
|
| 44 |
+
}
|
| 45 |
+
}, [isDragging, dragStart, windowPos])
|
| 46 |
+
|
| 47 |
+
return (
|
| 48 |
+
<AnimatePresence>
|
| 49 |
+
{isOpen && (
|
| 50 |
+
<>
|
| 51 |
+
{/* Backdrop */}
|
| 52 |
+
<motion.div
|
| 53 |
+
initial={{ opacity: 0 }}
|
| 54 |
+
animate={{ opacity: 1 }}
|
| 55 |
+
exit={{ opacity: 0 }}
|
| 56 |
+
className="fixed inset-0 bg-black/30 backdrop-blur-sm z-50"
|
| 57 |
+
/>
|
| 58 |
+
|
| 59 |
+
{/* Modal */}
|
| 60 |
+
<motion.div
|
| 61 |
+
style={{ left: windowPos.x, top: windowPos.y }}
|
| 62 |
+
initial={{ scale: 0.9, opacity: 0 }}
|
| 63 |
+
animate={{ scale: 1, opacity: 1 }}
|
| 64 |
+
exit={{ scale: 0.9, opacity: 0 }}
|
| 65 |
+
transition={{ type: "spring", damping: 20, stiffness: 300 }}
|
| 66 |
+
className="fixed w-[600px] bg-white rounded-lg shadow-2xl z-50 select-none"
|
| 67 |
+
>
|
| 68 |
+
{/* Ubuntu-style Window Header */}
|
| 69 |
+
<div
|
| 70 |
+
onMouseDown={handleMouseDown}
|
| 71 |
+
className="h-11 bg-gradient-to-b from-[#f6f5f4] to-[#edebe9] border-b border-[#d0d0d0] flex items-center justify-between px-3 cursor-move rounded-t-lg"
|
| 72 |
+
>
|
| 73 |
+
<div className="flex items-center gap-2 flex-1">
|
| 74 |
+
<div className="flex items-center gap-1 window-controls">
|
| 75 |
+
<button
|
| 76 |
+
onClick={onClose}
|
| 77 |
+
className="w-5 h-5 rounded-full bg-[#E95420] hover:bg-[#d14818] flex items-center justify-center group"
|
| 78 |
+
>
|
| 79 |
+
<X size={12} weight="bold" className="text-white opacity-0 group-hover:opacity-100" />
|
| 80 |
+
</button>
|
| 81 |
+
<button className="w-5 h-5 rounded-full bg-[#ddd] hover:bg-[#ccc] flex items-center justify-center group">
|
| 82 |
+
<Minus size={12} weight="bold" className="text-[#666] opacity-0 group-hover:opacity-100" />
|
| 83 |
+
</button>
|
| 84 |
+
<button className="w-5 h-5 rounded-full bg-[#ddd] hover:bg-[#ccc] flex items-center justify-center group">
|
| 85 |
+
<Square size={10} weight="bold" className="text-[#666] opacity-0 group-hover:opacity-100" />
|
| 86 |
+
</button>
|
| 87 |
+
</div>
|
| 88 |
+
<div className="flex items-center gap-2 ml-2">
|
| 89 |
+
<Info size={18} weight="fill" className="text-[#E95420]" />
|
| 90 |
+
<span className="text-sm font-medium text-[#2c2c2c]">About This Application</span>
|
| 91 |
+
</div>
|
| 92 |
+
</div>
|
| 93 |
+
</div>
|
| 94 |
+
|
| 95 |
+
{/* Content */}
|
| 96 |
+
<div className="p-6 space-y-5 bg-white rounded-b-lg">
|
| 97 |
+
{/* Creator Info */}
|
| 98 |
+
<div className="flex items-start gap-4 p-4 bg-gradient-to-r from-[#E95420]/10 to-orange-100 rounded-lg border border-[#E95420]/20">
|
| 99 |
+
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-[#E95420] to-[#d14818] flex items-center justify-center flex-shrink-0">
|
| 100 |
+
<UserCircle size={24} weight="fill" className="text-white" />
|
| 101 |
+
</div>
|
| 102 |
+
<div>
|
| 103 |
+
<h3 className="text-base font-semibold text-[#2c2c2c] mb-1">Created By</h3>
|
| 104 |
+
<p className="text-[#555] font-medium">Reuben Chagas Fernandes</p>
|
| 105 |
+
</div>
|
| 106 |
+
</div>
|
| 107 |
+
|
| 108 |
+
{/* Purpose */}
|
| 109 |
+
<div className="flex items-start gap-4 p-4 bg-blue-50 rounded-lg border border-blue-200">
|
| 110 |
+
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-blue-500 to-blue-600 flex items-center justify-center flex-shrink-0">
|
| 111 |
+
<Target size={24} weight="fill" className="text-white" />
|
| 112 |
+
</div>
|
| 113 |
+
<div>
|
| 114 |
+
<h3 className="text-base font-semibold text-[#2c2c2c] mb-2">Purpose</h3>
|
| 115 |
+
<p className="text-[#555] leading-relaxed text-sm">
|
| 116 |
+
This application was created for sharing study material and making it easily
|
| 117 |
+
accessible through Claude. Our goal is to provide a seamless platform for
|
| 118 |
+
students to collaborate, share resources, and enhance their learning experience.
|
| 119 |
+
</p>
|
| 120 |
+
</div>
|
| 121 |
+
</div>
|
| 122 |
+
|
| 123 |
+
{/* Features */}
|
| 124 |
+
<div className="bg-gradient-to-br from-purple-50 to-pink-50 rounded-lg p-4 border border-purple-200">
|
| 125 |
+
<h4 className="text-sm font-semibold text-[#2c2c2c] uppercase tracking-wider mb-3 flex items-center gap-2">
|
| 126 |
+
<span className="w-1 h-4 bg-[#E95420] rounded-full"></span>
|
| 127 |
+
Key Features
|
| 128 |
+
</h4>
|
| 129 |
+
<ul className="space-y-2.5 text-[#555]">
|
| 130 |
+
<li className="flex items-center gap-3">
|
| 131 |
+
<span className="w-2 h-2 bg-blue-500 rounded-full flex-shrink-0" />
|
| 132 |
+
<span className="text-sm">Easy file upload and sharing</span>
|
| 133 |
+
</li>
|
| 134 |
+
<li className="flex items-center gap-3">
|
| 135 |
+
<span className="w-2 h-2 bg-purple-500 rounded-full flex-shrink-0" />
|
| 136 |
+
<span className="text-sm">Integration with Claude AI</span>
|
| 137 |
+
</li>
|
| 138 |
+
<li className="flex items-center gap-3">
|
| 139 |
+
<span className="w-2 h-2 bg-green-500 rounded-full flex-shrink-0" />
|
| 140 |
+
<span className="text-sm">Public folder for community sharing</span>
|
| 141 |
+
</li>
|
| 142 |
+
<li className="flex items-center gap-3">
|
| 143 |
+
<span className="w-2 h-2 bg-[#E95420] rounded-full flex-shrink-0" />
|
| 144 |
+
<span className="text-sm">Exam calendar and organization tools</span>
|
| 145 |
+
</li>
|
| 146 |
+
<li className="flex items-center gap-3">
|
| 147 |
+
<span className="w-2 h-2 bg-cyan-500 rounded-full flex-shrink-0" />
|
| 148 |
+
<span className="text-sm">Web browser with CORS proxy support</span>
|
| 149 |
+
</li>
|
| 150 |
+
<li className="flex items-center gap-3">
|
| 151 |
+
<span className="w-2 h-2 bg-orange-500 rounded-full flex-shrink-0" />
|
| 152 |
+
<span className="text-sm">Gemini AI chat assistant</span>
|
| 153 |
+
</li>
|
| 154 |
+
</ul>
|
| 155 |
+
</div>
|
| 156 |
+
|
| 157 |
+
{/* Footer Button */}
|
| 158 |
+
<div className="pt-2">
|
| 159 |
+
<button
|
| 160 |
+
onClick={onClose}
|
| 161 |
+
className="w-full py-2.5 bg-[#E95420] hover:bg-[#d14818] text-white rounded-lg font-medium transition-colors shadow-sm"
|
| 162 |
+
>
|
| 163 |
+
Close
|
| 164 |
+
</button>
|
| 165 |
+
</div>
|
| 166 |
+
</div>
|
| 167 |
+
</motion.div>
|
| 168 |
+
</>
|
| 169 |
+
)}
|
| 170 |
+
</AnimatePresence>
|
| 171 |
+
)
|
| 172 |
+
}
|
app/components/MatrixRain.tsx
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import React, { useEffect, useRef, useState } from 'react'
|
| 4 |
+
import { motion, AnimatePresence } from 'framer-motion'
|
| 5 |
+
|
| 6 |
+
interface MatrixRainProps {
|
| 7 |
+
isActive: boolean
|
| 8 |
+
onComplete?: () => void
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
export function MatrixRain({ isActive, onComplete }: MatrixRainProps) {
|
| 12 |
+
const canvasRef = useRef<HTMLCanvasElement>(null)
|
| 13 |
+
const [showText, setShowText] = useState(false)
|
| 14 |
+
|
| 15 |
+
useEffect(() => {
|
| 16 |
+
if (!isActive) return
|
| 17 |
+
|
| 18 |
+
const canvas = canvasRef.current
|
| 19 |
+
if (!canvas) return
|
| 20 |
+
|
| 21 |
+
const ctx = canvas.getContext('2d')
|
| 22 |
+
if (!ctx) return
|
| 23 |
+
|
| 24 |
+
// Set canvas size
|
| 25 |
+
canvas.width = window.innerWidth
|
| 26 |
+
canvas.height = window.innerHeight
|
| 27 |
+
|
| 28 |
+
// Matrix rain configuration
|
| 29 |
+
const fontSize = 14
|
| 30 |
+
const columns = Math.floor(canvas.width / fontSize)
|
| 31 |
+
const drops: number[] = []
|
| 32 |
+
|
| 33 |
+
// Initialize drops
|
| 34 |
+
for (let i = 0; i < columns; i++) {
|
| 35 |
+
drops[i] = Math.random() * -100
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
// Characters to use in the rain
|
| 39 |
+
const matrix = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789@#$%^&*()_+-=[]{}|;:<>,.?/~`'
|
| 40 |
+
const matrixArray = matrix.split('')
|
| 41 |
+
|
| 42 |
+
// Special word to display
|
| 43 |
+
const specialWord = 'REUBENOS'
|
| 44 |
+
let specialWordTimer: NodeJS.Timeout
|
| 45 |
+
|
| 46 |
+
// Show the special text after 1.5 seconds
|
| 47 |
+
if (isActive) {
|
| 48 |
+
specialWordTimer = setTimeout(() => {
|
| 49 |
+
setShowText(true)
|
| 50 |
+
}, 1500)
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
// Drawing function
|
| 54 |
+
function draw() {
|
| 55 |
+
if (!ctx || !canvas) return
|
| 56 |
+
|
| 57 |
+
// Semi-transparent black to create fade effect
|
| 58 |
+
ctx.fillStyle = 'rgba(0, 0, 0, 0.05)'
|
| 59 |
+
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
| 60 |
+
|
| 61 |
+
// Green text
|
| 62 |
+
ctx.fillStyle = '#0F0'
|
| 63 |
+
ctx.font = `${fontSize}px monospace`
|
| 64 |
+
|
| 65 |
+
// Draw drops
|
| 66 |
+
for (let i = 0; i < drops.length; i++) {
|
| 67 |
+
// Random character
|
| 68 |
+
const text = matrixArray[Math.floor(Math.random() * matrixArray.length)]
|
| 69 |
+
|
| 70 |
+
// Draw the character
|
| 71 |
+
ctx.fillText(text, i * fontSize, drops[i] * fontSize)
|
| 72 |
+
|
| 73 |
+
// Reset drop when it goes off screen
|
| 74 |
+
if (drops[i] * fontSize > canvas.height && Math.random() > 0.975) {
|
| 75 |
+
drops[i] = 0
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
// Move drop down
|
| 79 |
+
drops[i]++
|
| 80 |
+
}
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
// Animation loop
|
| 84 |
+
const interval = setInterval(draw, 35)
|
| 85 |
+
|
| 86 |
+
// Cleanup
|
| 87 |
+
return () => {
|
| 88 |
+
clearInterval(interval)
|
| 89 |
+
clearTimeout(specialWordTimer)
|
| 90 |
+
setShowText(false)
|
| 91 |
+
}
|
| 92 |
+
}, [isActive])
|
| 93 |
+
|
| 94 |
+
// Handle window resize
|
| 95 |
+
useEffect(() => {
|
| 96 |
+
if (!isActive) return
|
| 97 |
+
|
| 98 |
+
const handleResize = () => {
|
| 99 |
+
const canvas = canvasRef.current
|
| 100 |
+
if (canvas) {
|
| 101 |
+
canvas.width = window.innerWidth
|
| 102 |
+
canvas.height = window.innerHeight
|
| 103 |
+
}
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
window.addEventListener('resize', handleResize)
|
| 107 |
+
return () => window.removeEventListener('resize', handleResize)
|
| 108 |
+
}, [isActive])
|
| 109 |
+
|
| 110 |
+
// Auto-complete after 5 seconds
|
| 111 |
+
useEffect(() => {
|
| 112 |
+
if (!isActive) return
|
| 113 |
+
|
| 114 |
+
const timer = setTimeout(() => {
|
| 115 |
+
if (onComplete) {
|
| 116 |
+
onComplete()
|
| 117 |
+
}
|
| 118 |
+
}, 5000)
|
| 119 |
+
|
| 120 |
+
return () => clearTimeout(timer)
|
| 121 |
+
}, [isActive, onComplete])
|
| 122 |
+
|
| 123 |
+
return (
|
| 124 |
+
<AnimatePresence>
|
| 125 |
+
{isActive && (
|
| 126 |
+
<>
|
| 127 |
+
<motion.div
|
| 128 |
+
initial={{ opacity: 0 }}
|
| 129 |
+
animate={{ opacity: 1 }}
|
| 130 |
+
exit={{ opacity: 0 }}
|
| 131 |
+
transition={{ duration: 0.5 }}
|
| 132 |
+
className="fixed inset-0 z-[9999] bg-black"
|
| 133 |
+
>
|
| 134 |
+
<canvas
|
| 135 |
+
ref={canvasRef}
|
| 136 |
+
className="absolute inset-0"
|
| 137 |
+
style={{ display: 'block' }}
|
| 138 |
+
/>
|
| 139 |
+
|
| 140 |
+
{/* REUBENOS Text */}
|
| 141 |
+
<AnimatePresence>
|
| 142 |
+
{showText && (
|
| 143 |
+
<motion.div
|
| 144 |
+
initial={{ opacity: 0, scale: 0.5 }}
|
| 145 |
+
animate={{ opacity: 1, scale: 1 }}
|
| 146 |
+
exit={{ opacity: 0, scale: 0.8 }}
|
| 147 |
+
transition={{
|
| 148 |
+
duration: 0.8,
|
| 149 |
+
ease: 'easeOut'
|
| 150 |
+
}}
|
| 151 |
+
className="absolute inset-0 flex items-center justify-center pointer-events-none"
|
| 152 |
+
>
|
| 153 |
+
<div className="text-center">
|
| 154 |
+
<motion.h1
|
| 155 |
+
className="text-[#0F0] font-mono font-bold select-none"
|
| 156 |
+
style={{
|
| 157 |
+
fontSize: 'clamp(3rem, 10vw, 8rem)',
|
| 158 |
+
textShadow: `
|
| 159 |
+
0 0 10px #0F0,
|
| 160 |
+
0 0 20px #0F0,
|
| 161 |
+
0 0 30px #0F0,
|
| 162 |
+
0 0 40px #0A0,
|
| 163 |
+
0 0 70px #0A0,
|
| 164 |
+
0 0 80px #0A0,
|
| 165 |
+
0 0 100px #0A0,
|
| 166 |
+
0 0 150px #0A0
|
| 167 |
+
`,
|
| 168 |
+
letterSpacing: '0.2em'
|
| 169 |
+
}}
|
| 170 |
+
initial={{ y: 20 }}
|
| 171 |
+
animate={{
|
| 172 |
+
y: [20, -10, 0],
|
| 173 |
+
textShadow: [
|
| 174 |
+
`0 0 10px #0F0, 0 0 20px #0F0, 0 0 30px #0F0, 0 0 40px #0A0, 0 0 70px #0A0, 0 0 80px #0A0, 0 0 100px #0A0, 0 0 150px #0A0`,
|
| 175 |
+
`0 0 20px #0F0, 0 0 30px #0F0, 0 0 40px #0F0, 0 0 50px #0A0, 0 0 80px #0A0, 0 0 90px #0A0, 0 0 120px #0A0, 0 0 180px #0A0`,
|
| 176 |
+
`0 0 10px #0F0, 0 0 20px #0F0, 0 0 30px #0F0, 0 0 40px #0A0, 0 0 70px #0A0, 0 0 80px #0A0, 0 0 100px #0A0, 0 0 150px #0A0`
|
| 177 |
+
]
|
| 178 |
+
}}
|
| 179 |
+
transition={{
|
| 180 |
+
duration: 2,
|
| 181 |
+
repeat: Infinity,
|
| 182 |
+
repeatType: 'reverse',
|
| 183 |
+
ease: 'easeInOut'
|
| 184 |
+
}}
|
| 185 |
+
>
|
| 186 |
+
REUBENOS
|
| 187 |
+
</motion.h1>
|
| 188 |
+
|
| 189 |
+
<motion.div
|
| 190 |
+
initial={{ opacity: 0 }}
|
| 191 |
+
animate={{ opacity: [0, 1, 0] }}
|
| 192 |
+
transition={{
|
| 193 |
+
duration: 1.5,
|
| 194 |
+
repeat: Infinity,
|
| 195 |
+
repeatDelay: 0.5
|
| 196 |
+
}}
|
| 197 |
+
className="mt-8 text-[#0F0] font-mono text-xl"
|
| 198 |
+
style={{
|
| 199 |
+
textShadow: '0 0 5px #0F0, 0 0 10px #0F0'
|
| 200 |
+
}}
|
| 201 |
+
>
|
| 202 |
+
SYSTEM BREACH DETECTED
|
| 203 |
+
</motion.div>
|
| 204 |
+
|
| 205 |
+
<motion.div
|
| 206 |
+
initial={{ width: 0 }}
|
| 207 |
+
animate={{ width: '100%' }}
|
| 208 |
+
transition={{
|
| 209 |
+
duration: 3,
|
| 210 |
+
ease: 'linear'
|
| 211 |
+
}}
|
| 212 |
+
className="mt-4 h-1 bg-gradient-to-r from-transparent via-[#0F0] to-transparent mx-auto max-w-md"
|
| 213 |
+
style={{
|
| 214 |
+
boxShadow: '0 0 10px #0F0'
|
| 215 |
+
}}
|
| 216 |
+
/>
|
| 217 |
+
</div>
|
| 218 |
+
</motion.div>
|
| 219 |
+
)}
|
| 220 |
+
</AnimatePresence>
|
| 221 |
+
|
| 222 |
+
{/* Click to exit hint */}
|
| 223 |
+
<motion.div
|
| 224 |
+
initial={{ opacity: 0 }}
|
| 225 |
+
animate={{ opacity: 1 }}
|
| 226 |
+
transition={{ delay: 3, duration: 1 }}
|
| 227 |
+
className="absolute bottom-10 left-1/2 transform -translate-x-1/2 text-[#0F0] font-mono text-sm opacity-50"
|
| 228 |
+
>
|
| 229 |
+
Click anywhere to exit the Matrix
|
| 230 |
+
</motion.div>
|
| 231 |
+
</motion.div>
|
| 232 |
+
</>
|
| 233 |
+
)}
|
| 234 |
+
</AnimatePresence>
|
| 235 |
+
)
|
| 236 |
+
}
|
app/components/PDFViewer.tsx
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import React, { useState } from 'react'
|
| 4 |
+
import dynamic from 'next/dynamic'
|
| 5 |
+
import { MagnifyingGlassPlus, MagnifyingGlassMinus, ArrowLeft, ArrowRight } from '@phosphor-icons/react'
|
| 6 |
+
|
| 7 |
+
// Dynamically import react-pdf with no SSR
|
| 8 |
+
const Document = dynamic(
|
| 9 |
+
() => import('react-pdf').then((mod) => mod.Document),
|
| 10 |
+
{ ssr: false }
|
| 11 |
+
)
|
| 12 |
+
const Page = dynamic(
|
| 13 |
+
() => import('react-pdf').then((mod) => mod.Page),
|
| 14 |
+
{ ssr: false }
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
// Configure PDF.js worker
|
| 18 |
+
if (typeof window !== 'undefined') {
|
| 19 |
+
import('react-pdf').then((pdfjs) => {
|
| 20 |
+
pdfjs.pdfjs.GlobalWorkerOptions.workerSrc = `//unpkg.com/pdfjs-dist@${pdfjs.pdfjs.version}/build/pdf.worker.min.mjs`
|
| 21 |
+
})
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
interface PDFViewerProps {
|
| 25 |
+
url: string
|
| 26 |
+
scale: number
|
| 27 |
+
onZoomIn: () => void
|
| 28 |
+
onZoomOut: () => void
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
export function PDFViewer({ url, scale, onZoomIn, onZoomOut }: PDFViewerProps) {
|
| 32 |
+
const [numPages, setNumPages] = useState<number | null>(null)
|
| 33 |
+
const [pageNumber, setPageNumber] = useState(1)
|
| 34 |
+
const [error, setError] = useState<string | null>(null)
|
| 35 |
+
const [isLoading, setIsLoading] = useState(true)
|
| 36 |
+
|
| 37 |
+
const onDocumentLoadSuccess = ({ numPages }: { numPages: number }) => {
|
| 38 |
+
setNumPages(numPages)
|
| 39 |
+
setPageNumber(1)
|
| 40 |
+
setError(null)
|
| 41 |
+
setIsLoading(false)
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
const changePage = (offset: number) => {
|
| 45 |
+
setPageNumber(prevPageNumber => {
|
| 46 |
+
const newPageNumber = prevPageNumber + offset
|
| 47 |
+
if (newPageNumber < 1 || newPageNumber > (numPages || 1)) {
|
| 48 |
+
return prevPageNumber
|
| 49 |
+
}
|
| 50 |
+
return newPageNumber
|
| 51 |
+
})
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
if (!url) {
|
| 55 |
+
return <div className="flex items-center justify-center h-full text-gray-500">No PDF to display</div>
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
return (
|
| 59 |
+
<div className="flex flex-col h-full">
|
| 60 |
+
<div className="flex items-center gap-2 p-3 border-b border-gray-200 bg-gray-50">
|
| 61 |
+
<button
|
| 62 |
+
onClick={onZoomOut}
|
| 63 |
+
className="p-2 hover:bg-gray-200 rounded-lg transition-colors"
|
| 64 |
+
title="Zoom Out"
|
| 65 |
+
>
|
| 66 |
+
<MagnifyingGlassMinus className="h-5 w-5" />
|
| 67 |
+
</button>
|
| 68 |
+
<span className="text-sm text-gray-600 min-w-[60px] text-center">{Math.round(scale * 100)}%</span>
|
| 69 |
+
<button
|
| 70 |
+
onClick={onZoomIn}
|
| 71 |
+
className="p-2 hover:bg-gray-200 rounded-lg transition-colors"
|
| 72 |
+
title="Zoom In"
|
| 73 |
+
>
|
| 74 |
+
<MagnifyingGlassPlus className="h-5 w-5" />
|
| 75 |
+
</button>
|
| 76 |
+
{numPages && (
|
| 77 |
+
<>
|
| 78 |
+
<div className="mx-4 h-6 w-px bg-gray-300" />
|
| 79 |
+
<button
|
| 80 |
+
onClick={() => changePage(-1)}
|
| 81 |
+
disabled={pageNumber <= 1}
|
| 82 |
+
className="p-2 hover:bg-gray-200 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
| 83 |
+
title="Previous Page"
|
| 84 |
+
>
|
| 85 |
+
<ArrowLeft className="h-5 w-5" />
|
| 86 |
+
</button>
|
| 87 |
+
<span className="text-sm text-gray-600 min-w-[80px] text-center">
|
| 88 |
+
Page {pageNumber} of {numPages}
|
| 89 |
+
</span>
|
| 90 |
+
<button
|
| 91 |
+
onClick={() => changePage(1)}
|
| 92 |
+
disabled={pageNumber >= numPages}
|
| 93 |
+
className="p-2 hover:bg-gray-200 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
| 94 |
+
title="Next Page"
|
| 95 |
+
>
|
| 96 |
+
<ArrowRight className="h-5 w-5" />
|
| 97 |
+
</button>
|
| 98 |
+
</>
|
| 99 |
+
)}
|
| 100 |
+
</div>
|
| 101 |
+
<div className="flex-1 overflow-auto p-4 bg-gray-50">
|
| 102 |
+
{typeof window !== 'undefined' && url && (
|
| 103 |
+
<Document
|
| 104 |
+
file={url}
|
| 105 |
+
onLoadSuccess={onDocumentLoadSuccess}
|
| 106 |
+
onLoadError={(err) => {
|
| 107 |
+
console.warn('PDF load error:', err)
|
| 108 |
+
setError('Unable to load PDF. The file might be corrupted or in an unsupported format.')
|
| 109 |
+
setIsLoading(false)
|
| 110 |
+
}}
|
| 111 |
+
loading={
|
| 112 |
+
<div className="flex items-center justify-center h-full">
|
| 113 |
+
<div className="text-gray-600">Loading PDF...</div>
|
| 114 |
+
</div>
|
| 115 |
+
}
|
| 116 |
+
error={
|
| 117 |
+
<div className="flex flex-col items-center justify-center h-full">
|
| 118 |
+
<div className="text-red-500 mb-2">Failed to load PDF</div>
|
| 119 |
+
<div className="text-sm text-gray-600">{error || 'The file might be corrupted or in an unsupported format.'}</div>
|
| 120 |
+
</div>
|
| 121 |
+
}
|
| 122 |
+
className="flex justify-center"
|
| 123 |
+
>
|
| 124 |
+
{!isLoading && !error && (
|
| 125 |
+
<Page
|
| 126 |
+
pageNumber={pageNumber}
|
| 127 |
+
scale={scale}
|
| 128 |
+
renderTextLayer={false}
|
| 129 |
+
renderAnnotationLayer={false}
|
| 130 |
+
className="shadow-lg bg-white"
|
| 131 |
+
loading={
|
| 132 |
+
<div className="flex items-center justify-center h-96 bg-white shadow-lg">
|
| 133 |
+
<div className="text-gray-400">Loading page...</div>
|
| 134 |
+
</div>
|
| 135 |
+
}
|
| 136 |
+
/>
|
| 137 |
+
)}
|
| 138 |
+
</Document>
|
| 139 |
+
)}
|
| 140 |
+
</div>
|
| 141 |
+
</div>
|
| 142 |
+
)
|
| 143 |
+
}
|
app/components/SpotlightSearch.tsx
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import React, { useState, useRef, useEffect } from 'react'
|
| 4 |
+
import { MagnifyingGlass, X } from '@phosphor-icons/react'
|
| 5 |
+
import { motion, AnimatePresence } from 'framer-motion'
|
| 6 |
+
|
| 7 |
+
interface SpotlightSearchProps {
|
| 8 |
+
isOpen: boolean
|
| 9 |
+
onClose: () => void
|
| 10 |
+
onOpenApp: (appId: string) => void
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
interface SearchResult {
|
| 14 |
+
id: string
|
| 15 |
+
name: string
|
| 16 |
+
type: 'app' | 'file' | 'setting'
|
| 17 |
+
icon?: React.ReactNode
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
export function SpotlightSearch({ isOpen, onClose, onOpenApp }: SpotlightSearchProps) {
|
| 21 |
+
const [query, setQuery] = useState('')
|
| 22 |
+
const [results, setResults] = useState<SearchResult[]>([])
|
| 23 |
+
const inputRef = useRef<HTMLInputElement>(null)
|
| 24 |
+
|
| 25 |
+
const apps: SearchResult[] = [
|
| 26 |
+
{ id: 'files', name: 'Files', type: 'app' },
|
| 27 |
+
{ id: 'calendar', name: 'Calendar', type: 'app' },
|
| 28 |
+
{ id: 'clock', name: 'Clock', type: 'app' },
|
| 29 |
+
{ id: 'browser', name: 'Web Browser', type: 'app' },
|
| 30 |
+
{ id: 'gemini', name: 'Gemini Chat', type: 'app' },
|
| 31 |
+
{ id: 'terminal', name: 'Terminal', type: 'app' },
|
| 32 |
+
{ id: 'vscode', name: 'VS Code', type: 'app' },
|
| 33 |
+
]
|
| 34 |
+
|
| 35 |
+
const files: SearchResult[] = [
|
| 36 |
+
{ id: 'document', name: 'Document.pdf', type: 'file' },
|
| 37 |
+
{ id: 'resume', name: 'Resume.docx', type: 'file' },
|
| 38 |
+
{ id: 'budget', name: 'Budget.xlsx', type: 'file' },
|
| 39 |
+
{ id: 'presentation', name: 'Presentation.pptx', type: 'file' },
|
| 40 |
+
]
|
| 41 |
+
|
| 42 |
+
const settings: SearchResult[] = [
|
| 43 |
+
{ id: 'wallpaper', name: 'Change Wallpaper', type: 'setting' },
|
| 44 |
+
{ id: 'theme', name: 'Dark Mode', type: 'setting' },
|
| 45 |
+
{ id: 'display', name: 'Display Settings', type: 'setting' },
|
| 46 |
+
]
|
| 47 |
+
|
| 48 |
+
const allItems = [...apps, ...files, ...settings]
|
| 49 |
+
|
| 50 |
+
useEffect(() => {
|
| 51 |
+
if (isOpen) {
|
| 52 |
+
setQuery('')
|
| 53 |
+
setResults([])
|
| 54 |
+
setTimeout(() => inputRef.current?.focus(), 100)
|
| 55 |
+
}
|
| 56 |
+
}, [isOpen])
|
| 57 |
+
|
| 58 |
+
useEffect(() => {
|
| 59 |
+
if (query.trim() === '') {
|
| 60 |
+
setResults([])
|
| 61 |
+
return
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
const filtered = allItems.filter(item =>
|
| 65 |
+
item.name.toLowerCase().includes(query.toLowerCase())
|
| 66 |
+
)
|
| 67 |
+
setResults(filtered.slice(0, 8))
|
| 68 |
+
}, [query])
|
| 69 |
+
|
| 70 |
+
const handleSelect = (result: SearchResult) => {
|
| 71 |
+
if (result.type === 'app') {
|
| 72 |
+
onOpenApp(result.id)
|
| 73 |
+
}
|
| 74 |
+
onClose()
|
| 75 |
+
setQuery('')
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
const handleKeyDown = (e: React.KeyboardEvent) => {
|
| 79 |
+
if (e.key === 'Escape') {
|
| 80 |
+
onClose()
|
| 81 |
+
} else if (e.key === 'Enter' && results.length > 0) {
|
| 82 |
+
handleSelect(results[0])
|
| 83 |
+
}
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
return (
|
| 87 |
+
<AnimatePresence>
|
| 88 |
+
{isOpen && (
|
| 89 |
+
<>
|
| 90 |
+
{/* Backdrop */}
|
| 91 |
+
<motion.div
|
| 92 |
+
initial={{ opacity: 0 }}
|
| 93 |
+
animate={{ opacity: 1 }}
|
| 94 |
+
exit={{ opacity: 0 }}
|
| 95 |
+
className="fixed inset-0 bg-black/20 z-[60]"
|
| 96 |
+
onClick={onClose}
|
| 97 |
+
/>
|
| 98 |
+
|
| 99 |
+
{/* Search Box */}
|
| 100 |
+
<motion.div
|
| 101 |
+
initial={{ opacity: 0, scale: 0.95, y: -20 }}
|
| 102 |
+
animate={{ opacity: 1, scale: 1, y: 0 }}
|
| 103 |
+
exit={{ opacity: 0, scale: 0.95, y: -20 }}
|
| 104 |
+
transition={{ duration: 0.15, ease: 'easeOut' }}
|
| 105 |
+
className="fixed top-[20%] left-1/2 -translate-x-1/2 w-[600px] max-w-[90%] glass rounded-xl shadow-2xl z-[70]"
|
| 106 |
+
>
|
| 107 |
+
<div className="flex items-center px-4 py-3 gap-3 border-b border-gray-200/20">
|
| 108 |
+
<MagnifyingGlass size={24} weight="regular" className="text-gray-600" />
|
| 109 |
+
<input
|
| 110 |
+
ref={inputRef}
|
| 111 |
+
type="text"
|
| 112 |
+
value={query}
|
| 113 |
+
onChange={(e) => setQuery(e.target.value)}
|
| 114 |
+
onKeyDown={handleKeyDown}
|
| 115 |
+
className="flex-1 bg-transparent text-xl focus:outline-none text-gray-800 placeholder-gray-400"
|
| 116 |
+
placeholder="Spotlight Search"
|
| 117 |
+
autoComplete="off"
|
| 118 |
+
spellCheck={false}
|
| 119 |
+
/>
|
| 120 |
+
<button
|
| 121 |
+
onClick={onClose}
|
| 122 |
+
className="text-gray-500 hover:text-gray-700 transition-colors"
|
| 123 |
+
>
|
| 124 |
+
<X size={20} weight="regular" />
|
| 125 |
+
</button>
|
| 126 |
+
</div>
|
| 127 |
+
|
| 128 |
+
{/* Results */}
|
| 129 |
+
{results.length > 0 && (
|
| 130 |
+
<div className="max-h-96 overflow-y-auto p-2">
|
| 131 |
+
{results.map((result, index) => (
|
| 132 |
+
<div
|
| 133 |
+
key={result.id}
|
| 134 |
+
onClick={() => handleSelect(result)}
|
| 135 |
+
className={`flex items-center px-3 py-2.5 hover:bg-blue-500 hover:text-white rounded-lg cursor-pointer transition-colors ${
|
| 136 |
+
index === 0 ? 'bg-blue-500/10' : ''
|
| 137 |
+
}`}
|
| 138 |
+
>
|
| 139 |
+
<div className="flex-1 flex flex-col">
|
| 140 |
+
<span className="font-medium">{result.name}</span>
|
| 141 |
+
<span className="text-xs opacity-70 capitalize">{result.type}</span>
|
| 142 |
+
</div>
|
| 143 |
+
{result.type === 'app' && (
|
| 144 |
+
<span className="text-xs opacity-50">⌘ Enter</span>
|
| 145 |
+
)}
|
| 146 |
+
</div>
|
| 147 |
+
))}
|
| 148 |
+
</div>
|
| 149 |
+
)}
|
| 150 |
+
|
| 151 |
+
{/* No results */}
|
| 152 |
+
{query.trim() !== '' && results.length === 0 && (
|
| 153 |
+
<div className="p-4 text-center text-gray-500">
|
| 154 |
+
No results found for "{query}"
|
| 155 |
+
</div>
|
| 156 |
+
)}
|
| 157 |
+
|
| 158 |
+
{/* Quick Actions (when no query) */}
|
| 159 |
+
{query === '' && (
|
| 160 |
+
<div className="p-4">
|
| 161 |
+
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">
|
| 162 |
+
Suggestions
|
| 163 |
+
</div>
|
| 164 |
+
<div className="grid grid-cols-2 gap-2">
|
| 165 |
+
{apps.slice(0, 4).map(app => (
|
| 166 |
+
<div
|
| 167 |
+
key={app.id}
|
| 168 |
+
onClick={() => handleSelect(app)}
|
| 169 |
+
className="flex items-center px-3 py-2 hover:bg-gray-100 rounded-lg cursor-pointer transition-colors"
|
| 170 |
+
>
|
| 171 |
+
<span className="text-sm">{app.name}</span>
|
| 172 |
+
</div>
|
| 173 |
+
))}
|
| 174 |
+
</div>
|
| 175 |
+
</div>
|
| 176 |
+
)}
|
| 177 |
+
</motion.div>
|
| 178 |
+
</>
|
| 179 |
+
)}
|
| 180 |
+
</AnimatePresence>
|
| 181 |
+
)
|
| 182 |
+
}
|
app/components/Terminal.tsx
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import React, { useState, useRef, useEffect } from 'react'
|
| 4 |
+
import Window from './Window'
|
| 5 |
+
|
| 6 |
+
interface TerminalProps {
|
| 7 |
+
onClose: () => void
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
interface FileSystem {
|
| 11 |
+
[key: string]: string[]
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
export function Terminal({ onClose }: TerminalProps) {
|
| 15 |
+
const [history, setHistory] = useState<string[]>([
|
| 16 |
+
'Last login: ' + new Date().toLocaleString() + ' on ttys000'
|
| 17 |
+
])
|
| 18 |
+
const [currentInput, setCurrentInput] = useState('')
|
| 19 |
+
const [currentPath, setCurrentPath] = useState('~')
|
| 20 |
+
const inputRef = useRef<HTMLInputElement>(null)
|
| 21 |
+
const outputRef = useRef<HTMLDivElement>(null)
|
| 22 |
+
|
| 23 |
+
const fileSystem: FileSystem = {
|
| 24 |
+
'~': ['Desktop', 'Documents', 'Downloads', 'Pictures', 'Music', 'Videos'],
|
| 25 |
+
'Desktop': ['screenshot.png', 'notes.txt', 'project'],
|
| 26 |
+
'Documents': ['resume.pdf', 'budget.xlsx', 'report.docx'],
|
| 27 |
+
'Downloads': ['installer.dmg', 'image.jpg', 'archive.zip'],
|
| 28 |
+
'Pictures': ['vacation.jpg', 'family.png'],
|
| 29 |
+
'Music': ['song.mp3', 'playlist.m3u'],
|
| 30 |
+
'Videos': ['tutorial.mp4', 'presentation.mov'],
|
| 31 |
+
'project': ['index.html', 'style.css', 'script.js', 'README.md']
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
useEffect(() => {
|
| 35 |
+
if (outputRef.current) {
|
| 36 |
+
outputRef.current.scrollTop = outputRef.current.scrollHeight
|
| 37 |
+
}
|
| 38 |
+
}, [history])
|
| 39 |
+
|
| 40 |
+
const handleCommand = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
| 41 |
+
if (e.key === 'Enter') {
|
| 42 |
+
const cmd = currentInput.trim()
|
| 43 |
+
const newHistory = [...history]
|
| 44 |
+
|
| 45 |
+
// Add command to history
|
| 46 |
+
newHistory.push(`guest@studyos:${currentPath} $ ${cmd}`)
|
| 47 |
+
|
| 48 |
+
// Process command
|
| 49 |
+
const args = cmd.split(' ')
|
| 50 |
+
const command = args[0].toLowerCase()
|
| 51 |
+
|
| 52 |
+
switch (command) {
|
| 53 |
+
case 'help':
|
| 54 |
+
newHistory.push('Available commands:')
|
| 55 |
+
newHistory.push(' help - Show this help message')
|
| 56 |
+
newHistory.push(' ls - List directory contents')
|
| 57 |
+
newHistory.push(' cd - Change directory')
|
| 58 |
+
newHistory.push(' pwd - Print working directory')
|
| 59 |
+
newHistory.push(' clear - Clear terminal')
|
| 60 |
+
newHistory.push(' date - Show current date and time')
|
| 61 |
+
newHistory.push(' whoami - Display current user')
|
| 62 |
+
newHistory.push(' echo - Display message')
|
| 63 |
+
newHistory.push(' cat - Display file contents')
|
| 64 |
+
newHistory.push(' mkdir - Create directory')
|
| 65 |
+
newHistory.push(' touch - Create file')
|
| 66 |
+
newHistory.push(' rm - Remove file')
|
| 67 |
+
newHistory.push(' open - Open application')
|
| 68 |
+
break
|
| 69 |
+
|
| 70 |
+
case 'ls':
|
| 71 |
+
const files = fileSystem[currentPath] || []
|
| 72 |
+
if (files.length > 0) {
|
| 73 |
+
const fileList = files.map(f => {
|
| 74 |
+
const isDir = fileSystem[f] !== undefined
|
| 75 |
+
return isDir ?
|
| 76 |
+
`\x1b[34m${f}/\x1b[0m` : // Blue for directories
|
| 77 |
+
f
|
| 78 |
+
}).join(' ')
|
| 79 |
+
newHistory.push(fileList)
|
| 80 |
+
} else {
|
| 81 |
+
newHistory.push('Directory is empty')
|
| 82 |
+
}
|
| 83 |
+
break
|
| 84 |
+
|
| 85 |
+
case 'cd':
|
| 86 |
+
if (args[1] === '..') {
|
| 87 |
+
setCurrentPath('~')
|
| 88 |
+
} else if (args[1] === '~' || !args[1]) {
|
| 89 |
+
setCurrentPath('~')
|
| 90 |
+
} else if (fileSystem[args[1]]) {
|
| 91 |
+
setCurrentPath(args[1])
|
| 92 |
+
} else {
|
| 93 |
+
newHistory.push(`cd: no such file or directory: ${args[1]}`)
|
| 94 |
+
}
|
| 95 |
+
break
|
| 96 |
+
|
| 97 |
+
case 'pwd':
|
| 98 |
+
newHistory.push(currentPath === '~' ? '/home/guest' : `/home/guest/${currentPath}`)
|
| 99 |
+
break
|
| 100 |
+
|
| 101 |
+
case 'clear':
|
| 102 |
+
setHistory(['Last login: ' + new Date().toLocaleString() + ' on ttys000'])
|
| 103 |
+
setCurrentInput('')
|
| 104 |
+
return
|
| 105 |
+
|
| 106 |
+
case 'date':
|
| 107 |
+
newHistory.push(new Date().toString())
|
| 108 |
+
break
|
| 109 |
+
|
| 110 |
+
case 'whoami':
|
| 111 |
+
newHistory.push('guest')
|
| 112 |
+
break
|
| 113 |
+
|
| 114 |
+
case 'echo':
|
| 115 |
+
newHistory.push(args.slice(1).join(' '))
|
| 116 |
+
break
|
| 117 |
+
|
| 118 |
+
case 'cat':
|
| 119 |
+
if (args[1]) {
|
| 120 |
+
const files = fileSystem[currentPath] || []
|
| 121 |
+
if (files.includes(args[1])) {
|
| 122 |
+
newHistory.push(`Contents of ${args[1]}:`)
|
| 123 |
+
newHistory.push('(This is a simulated file content)')
|
| 124 |
+
} else {
|
| 125 |
+
newHistory.push(`cat: ${args[1]}: No such file`)
|
| 126 |
+
}
|
| 127 |
+
} else {
|
| 128 |
+
newHistory.push('Usage: cat <filename>')
|
| 129 |
+
}
|
| 130 |
+
break
|
| 131 |
+
|
| 132 |
+
case 'mkdir':
|
| 133 |
+
if (args[1]) {
|
| 134 |
+
newHistory.push(`Directory '${args[1]}' created`)
|
| 135 |
+
} else {
|
| 136 |
+
newHistory.push('Usage: mkdir <dirname>')
|
| 137 |
+
}
|
| 138 |
+
break
|
| 139 |
+
|
| 140 |
+
case 'touch':
|
| 141 |
+
if (args[1]) {
|
| 142 |
+
newHistory.push(`File '${args[1]}' created`)
|
| 143 |
+
} else {
|
| 144 |
+
newHistory.push('Usage: touch <filename>')
|
| 145 |
+
}
|
| 146 |
+
break
|
| 147 |
+
|
| 148 |
+
case 'rm':
|
| 149 |
+
if (args[1]) {
|
| 150 |
+
newHistory.push(`File '${args[1]}' removed`)
|
| 151 |
+
} else {
|
| 152 |
+
newHistory.push('Usage: rm <filename>')
|
| 153 |
+
}
|
| 154 |
+
break
|
| 155 |
+
|
| 156 |
+
case 'open':
|
| 157 |
+
if (args[1]) {
|
| 158 |
+
newHistory.push(`Opening ${args[1]}...`)
|
| 159 |
+
} else {
|
| 160 |
+
newHistory.push('Usage: open <application>')
|
| 161 |
+
}
|
| 162 |
+
break
|
| 163 |
+
|
| 164 |
+
case '':
|
| 165 |
+
break
|
| 166 |
+
|
| 167 |
+
default:
|
| 168 |
+
newHistory.push(`zsh: command not found: ${command}`)
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
setHistory(newHistory)
|
| 172 |
+
setCurrentInput('')
|
| 173 |
+
}
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
return (
|
| 177 |
+
<Window
|
| 178 |
+
id="terminal"
|
| 179 |
+
title="Terminal — zsh — 80×24"
|
| 180 |
+
isOpen={true}
|
| 181 |
+
onClose={onClose}
|
| 182 |
+
width={600}
|
| 183 |
+
height={400}
|
| 184 |
+
x={120}
|
| 185 |
+
y={120}
|
| 186 |
+
darkMode={true}
|
| 187 |
+
className="terminal-window"
|
| 188 |
+
>
|
| 189 |
+
<div
|
| 190 |
+
className="flex-1 bg-[#1e1e1e] p-2 font-mono text-sm text-white overflow-auto"
|
| 191 |
+
ref={outputRef}
|
| 192 |
+
onClick={() => inputRef.current?.focus()}
|
| 193 |
+
>
|
| 194 |
+
{history.map((line, index) => (
|
| 195 |
+
<div
|
| 196 |
+
key={index}
|
| 197 |
+
className="whitespace-pre-wrap"
|
| 198 |
+
dangerouslySetInnerHTML={{
|
| 199 |
+
__html: line
|
| 200 |
+
.replace(/\x1b\[34m/g, '<span style="color: #4FC3F7;">')
|
| 201 |
+
.replace(/\x1b\[0m/g, '</span>')
|
| 202 |
+
.replace(/guest@studyos/g, '<span style="color: #4CAF50;">guest@studyos</span>')
|
| 203 |
+
}}
|
| 204 |
+
/>
|
| 205 |
+
))}
|
| 206 |
+
<div className="flex">
|
| 207 |
+
<span className="text-green-400 mr-2">guest@studyos:{currentPath} $</span>
|
| 208 |
+
<input
|
| 209 |
+
ref={inputRef}
|
| 210 |
+
type="text"
|
| 211 |
+
value={currentInput}
|
| 212 |
+
onChange={(e) => setCurrentInput(e.target.value)}
|
| 213 |
+
onKeyDown={handleCommand}
|
| 214 |
+
className="flex-1 bg-transparent border-none outline-none text-white"
|
| 215 |
+
autoFocus
|
| 216 |
+
autoComplete="off"
|
| 217 |
+
spellCheck={false}
|
| 218 |
+
/>
|
| 219 |
+
</div>
|
| 220 |
+
</div>
|
| 221 |
+
</Window>
|
| 222 |
+
)
|
| 223 |
+
}
|
app/components/TopBar.tsx
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import React, { useState, useEffect } from 'react'
|
| 4 |
+
import {
|
| 5 |
+
SpeakerHigh,
|
| 6 |
+
WifiHigh,
|
| 7 |
+
BatteryFull,
|
| 8 |
+
MagnifyingGlass,
|
| 9 |
+
Desktop,
|
| 10 |
+
CirclesFour
|
| 11 |
+
} from '@phosphor-icons/react'
|
| 12 |
+
import { motion, AnimatePresence } from 'framer-motion'
|
| 13 |
+
|
| 14 |
+
interface TopBarProps {
|
| 15 |
+
onPowerAction?: () => void
|
| 16 |
+
activeAppName?: string
|
| 17 |
+
onAboutClick?: () => void
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
export function TopBar({ onPowerAction, activeAppName = 'Finder', onAboutClick }: TopBarProps) {
|
| 21 |
+
const [appleMenuOpen, setAppleMenuOpen] = useState(false)
|
| 22 |
+
const [currentTime, setCurrentTime] = useState('')
|
| 23 |
+
|
| 24 |
+
useEffect(() => {
|
| 25 |
+
const updateTime = () => {
|
| 26 |
+
const now = new Date()
|
| 27 |
+
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
|
| 28 |
+
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
|
| 29 |
+
|
| 30 |
+
const timeString = now.toLocaleTimeString('en-US', {
|
| 31 |
+
hour: 'numeric',
|
| 32 |
+
minute: '2-digit',
|
| 33 |
+
hour12: true
|
| 34 |
+
})
|
| 35 |
+
const dateString = `${days[now.getDay()]} ${now.getDate()} ${months[now.getMonth()]}`
|
| 36 |
+
setCurrentTime(`${dateString} ${timeString}`)
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
updateTime()
|
| 40 |
+
const interval = setInterval(updateTime, 1000)
|
| 41 |
+
return () => clearInterval(interval)
|
| 42 |
+
}, [])
|
| 43 |
+
|
| 44 |
+
return (
|
| 45 |
+
<div className="fixed top-0 left-0 right-0 h-8 glass flex items-center justify-between px-4 text-sm z-50 shadow-sm">
|
| 46 |
+
<div className="flex items-center space-x-4">
|
| 47 |
+
<button
|
| 48 |
+
onClick={() => setAppleMenuOpen(!appleMenuOpen)}
|
| 49 |
+
className="font-bold text-lg hover:text-blue-600 transition-colors relative"
|
| 50 |
+
>
|
| 51 |
+
<CirclesFour size={20} weight="fill" className="text-blue-600" />
|
| 52 |
+
</button>
|
| 53 |
+
<span className="font-bold text-gray-800">Reuben OS</span>
|
| 54 |
+
<span className="text-gray-600 text-xs ml-2">{activeAppName}</span>
|
| 55 |
+
</div>
|
| 56 |
+
|
| 57 |
+
<div className="flex items-center space-x-4">
|
| 58 |
+
<BatteryFull size={16} weight="fill" className="text-gray-700" />
|
| 59 |
+
<WifiHigh size={16} weight="fill" className="text-gray-700" />
|
| 60 |
+
<MagnifyingGlass size={16} weight="regular" className="text-gray-700" />
|
| 61 |
+
<div className="font-medium text-gray-800">{currentTime}</div>
|
| 62 |
+
</div>
|
| 63 |
+
|
| 64 |
+
<AnimatePresence>
|
| 65 |
+
{appleMenuOpen && (
|
| 66 |
+
<>
|
| 67 |
+
<div
|
| 68 |
+
className="fixed inset-0 z-[55]"
|
| 69 |
+
onClick={() => setAppleMenuOpen(false)}
|
| 70 |
+
/>
|
| 71 |
+
<motion.div
|
| 72 |
+
initial={{ opacity: 0, y: -5 }}
|
| 73 |
+
animate={{ opacity: 1, y: 0 }}
|
| 74 |
+
exit={{ opacity: 0, y: -5 }}
|
| 75 |
+
transition={{ duration: 0.15 }}
|
| 76 |
+
className="absolute top-8 left-2 w-48 glass rounded-lg shadow-xl flex flex-col py-1 z-[60] text-sm"
|
| 77 |
+
>
|
| 78 |
+
<button
|
| 79 |
+
onClick={() => {
|
| 80 |
+
if (onAboutClick) onAboutClick()
|
| 81 |
+
setAppleMenuOpen(false)
|
| 82 |
+
}}
|
| 83 |
+
className="text-left px-4 py-1 hover:bg-blue-500 hover:text-white transition-colors text-gray-800">
|
| 84 |
+
About Reuben OS
|
| 85 |
+
</button>
|
| 86 |
+
<div className="h-px bg-gray-300 my-1 mx-2" />
|
| 87 |
+
<button className="text-left px-4 py-1 hover:bg-blue-500 hover:text-white transition-colors text-gray-800">
|
| 88 |
+
System Settings...
|
| 89 |
+
</button>
|
| 90 |
+
<div className="h-px bg-gray-300 my-1 mx-2" />
|
| 91 |
+
<button className="text-left px-4 py-1 hover:bg-blue-500 hover:text-white transition-colors text-gray-800">
|
| 92 |
+
Sleep
|
| 93 |
+
</button>
|
| 94 |
+
<button className="text-left px-4 py-1 hover:bg-blue-500 hover:text-white transition-colors text-gray-800">
|
| 95 |
+
Restart...
|
| 96 |
+
</button>
|
| 97 |
+
<button
|
| 98 |
+
onClick={() => {
|
| 99 |
+
if (onPowerAction) onPowerAction()
|
| 100 |
+
setAppleMenuOpen(false)
|
| 101 |
+
}}
|
| 102 |
+
className="text-left px-4 py-1 hover:bg-blue-500 hover:text-white transition-colors text-gray-800"
|
| 103 |
+
>
|
| 104 |
+
Shut Down...
|
| 105 |
+
</button>
|
| 106 |
+
<div className="h-px bg-gray-300 my-1 mx-2" />
|
| 107 |
+
<button
|
| 108 |
+
onClick={() => {
|
| 109 |
+
if (onPowerAction) onPowerAction()
|
| 110 |
+
setAppleMenuOpen(false)
|
| 111 |
+
}}
|
| 112 |
+
className="text-left px-4 py-1 hover:bg-blue-500 hover:text-white transition-colors text-gray-800"
|
| 113 |
+
>
|
| 114 |
+
Log Out...
|
| 115 |
+
</button>
|
| 116 |
+
</motion.div>
|
| 117 |
+
</>
|
| 118 |
+
)}
|
| 119 |
+
</AnimatePresence>
|
| 120 |
+
</div>
|
| 121 |
+
)
|
| 122 |
+
}
|
app/components/VSCodeEditor.tsx
ADDED
|
@@ -0,0 +1,458 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import React, { useState, useRef, useEffect } from 'react'
|
| 4 |
+
import Window from './Window'
|
| 5 |
+
import Editor from '@monaco-editor/react'
|
| 6 |
+
import {
|
| 7 |
+
Code,
|
| 8 |
+
Play,
|
| 9 |
+
FileHtml,
|
| 10 |
+
FileCss,
|
| 11 |
+
FileJs,
|
| 12 |
+
Download,
|
| 13 |
+
Upload,
|
| 14 |
+
FloppyDisk,
|
| 15 |
+
Eye,
|
| 16 |
+
EyeSlash,
|
| 17 |
+
ArrowsOutSimple,
|
| 18 |
+
ArrowsInSimple,
|
| 19 |
+
X
|
| 20 |
+
} from '@phosphor-icons/react'
|
| 21 |
+
|
| 22 |
+
interface VSCodeEditorProps {
|
| 23 |
+
onClose: () => void
|
| 24 |
+
userSession?: string
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
interface Tab {
|
| 28 |
+
id: string
|
| 29 |
+
name: string
|
| 30 |
+
language: string
|
| 31 |
+
content: string
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
export function VSCodeEditor({ onClose, userSession }: VSCodeEditorProps) {
|
| 35 |
+
const [activeTab, setActiveTab] = useState('html')
|
| 36 |
+
const [showPreview, setShowPreview] = useState(true)
|
| 37 |
+
const [isFullscreen, setIsFullscreen] = useState(false)
|
| 38 |
+
const [isSaving, setIsSaving] = useState(false)
|
| 39 |
+
const previewRef = useRef<HTMLIFrameElement>(null)
|
| 40 |
+
|
| 41 |
+
// Generate unique session ID if not provided
|
| 42 |
+
const sessionId = userSession || `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
|
| 43 |
+
|
| 44 |
+
// Function to get icon for each tab
|
| 45 |
+
const getTabIcon = (tabId: string) => {
|
| 46 |
+
switch (tabId) {
|
| 47 |
+
case 'html':
|
| 48 |
+
return <FileHtml size={16} weight="fill" className="text-orange-500" />
|
| 49 |
+
case 'css':
|
| 50 |
+
return <FileCss size={16} weight="fill" className="text-blue-500" />
|
| 51 |
+
case 'js':
|
| 52 |
+
return <FileJs size={16} weight="fill" className="text-yellow-500" />
|
| 53 |
+
default:
|
| 54 |
+
return <Code size={16} weight="fill" className="text-gray-500" />
|
| 55 |
+
}
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
const [tabs, setTabs] = useState<Tab[]>([
|
| 59 |
+
{
|
| 60 |
+
id: 'html',
|
| 61 |
+
name: 'index.html',
|
| 62 |
+
language: 'html',
|
| 63 |
+
content: `<!DOCTYPE html>
|
| 64 |
+
<html lang="en">
|
| 65 |
+
<head>
|
| 66 |
+
<meta charset="UTF-8">
|
| 67 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 68 |
+
<title>My Awesome Page</title>
|
| 69 |
+
<link rel="stylesheet" href="style.css">
|
| 70 |
+
</head>
|
| 71 |
+
<body>
|
| 72 |
+
<div class="container">
|
| 73 |
+
<h1>Welcome to Reuben OS Editor!</h1>
|
| 74 |
+
<p>Start coding and see your changes live!</p>
|
| 75 |
+
<button onclick="showMessage()">Click Me!</button>
|
| 76 |
+
<div id="output"></div>
|
| 77 |
+
</div>
|
| 78 |
+
<script src="script.js"></script>
|
| 79 |
+
</body>
|
| 80 |
+
</html>`
|
| 81 |
+
},
|
| 82 |
+
{
|
| 83 |
+
id: 'css',
|
| 84 |
+
name: 'style.css',
|
| 85 |
+
language: 'css',
|
| 86 |
+
content: `* {
|
| 87 |
+
margin: 0;
|
| 88 |
+
padding: 0;
|
| 89 |
+
box-sizing: border-box;
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
body {
|
| 93 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
| 94 |
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
| 95 |
+
min-height: 100vh;
|
| 96 |
+
display: flex;
|
| 97 |
+
align-items: center;
|
| 98 |
+
justify-content: center;
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
.container {
|
| 102 |
+
background: rgba(255, 255, 255, 0.95);
|
| 103 |
+
padding: 40px;
|
| 104 |
+
border-radius: 20px;
|
| 105 |
+
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
| 106 |
+
text-align: center;
|
| 107 |
+
max-width: 500px;
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
h1 {
|
| 111 |
+
color: #333;
|
| 112 |
+
margin-bottom: 20px;
|
| 113 |
+
font-size: 2.5em;
|
| 114 |
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
| 115 |
+
-webkit-background-clip: text;
|
| 116 |
+
-webkit-text-fill-color: transparent;
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
p {
|
| 120 |
+
color: #666;
|
| 121 |
+
margin-bottom: 30px;
|
| 122 |
+
font-size: 1.1em;
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
button {
|
| 126 |
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
| 127 |
+
color: white;
|
| 128 |
+
border: none;
|
| 129 |
+
padding: 12px 30px;
|
| 130 |
+
border-radius: 50px;
|
| 131 |
+
font-size: 16px;
|
| 132 |
+
cursor: pointer;
|
| 133 |
+
transition: all 0.3s ease;
|
| 134 |
+
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
button:hover {
|
| 138 |
+
transform: translateY(-2px);
|
| 139 |
+
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.6);
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
#output {
|
| 143 |
+
margin-top: 20px;
|
| 144 |
+
padding: 15px;
|
| 145 |
+
background: #f7f7f7;
|
| 146 |
+
border-radius: 10px;
|
| 147 |
+
min-height: 50px;
|
| 148 |
+
font-family: 'Courier New', monospace;
|
| 149 |
+
}`
|
| 150 |
+
},
|
| 151 |
+
{
|
| 152 |
+
id: 'js',
|
| 153 |
+
name: 'script.js',
|
| 154 |
+
language: 'javascript',
|
| 155 |
+
content: `// Welcome to Reuben OS Code Editor!
|
| 156 |
+
|
| 157 |
+
function showMessage() {
|
| 158 |
+
const messages = [
|
| 159 |
+
"Hello from Reuben OS! 🚀",
|
| 160 |
+
"You're doing great! 💪",
|
| 161 |
+
"Keep coding! 🎉",
|
| 162 |
+
"Awesome work! ⭐",
|
| 163 |
+
"Reuben OS is amazing! 🌟"
|
| 164 |
+
];
|
| 165 |
+
|
| 166 |
+
const randomMessage = messages[Math.floor(Math.random() * messages.length)];
|
| 167 |
+
const output = document.getElementById('output');
|
| 168 |
+
|
| 169 |
+
output.innerHTML = \`
|
| 170 |
+
<strong>Message:</strong> \${randomMessage}<br>
|
| 171 |
+
<small>Generated at: \${new Date().toLocaleTimeString()}</small>
|
| 172 |
+
\`;
|
| 173 |
+
|
| 174 |
+
// Add animation
|
| 175 |
+
output.style.opacity = '0';
|
| 176 |
+
setTimeout(() => {
|
| 177 |
+
output.style.transition = 'opacity 0.5s ease';
|
| 178 |
+
output.style.opacity = '1';
|
| 179 |
+
}, 10);
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
// Log to console when page loads
|
| 183 |
+
console.log('Reuben OS Editor initialized!');
|
| 184 |
+
console.log('Session ID:', '\${sessionId}');`
|
| 185 |
+
}
|
| 186 |
+
])
|
| 187 |
+
|
| 188 |
+
// Load saved code from localStorage based on session
|
| 189 |
+
useEffect(() => {
|
| 190 |
+
const savedCode = localStorage.getItem(`vscode_${sessionId}`)
|
| 191 |
+
if (savedCode) {
|
| 192 |
+
try {
|
| 193 |
+
const parsed = JSON.parse(savedCode)
|
| 194 |
+
setTabs(parsed)
|
| 195 |
+
} catch (e) {
|
| 196 |
+
console.error('Failed to load saved code')
|
| 197 |
+
}
|
| 198 |
+
}
|
| 199 |
+
}, [sessionId])
|
| 200 |
+
|
| 201 |
+
// Save code to localStorage
|
| 202 |
+
useEffect(() => {
|
| 203 |
+
if (tabs.length > 0) {
|
| 204 |
+
localStorage.setItem(`vscode_${sessionId}`, JSON.stringify(tabs))
|
| 205 |
+
}
|
| 206 |
+
}, [tabs, sessionId])
|
| 207 |
+
|
| 208 |
+
// Update preview when code changes
|
| 209 |
+
useEffect(() => {
|
| 210 |
+
if (previewRef.current && showPreview) {
|
| 211 |
+
updatePreview()
|
| 212 |
+
}
|
| 213 |
+
}, [tabs, showPreview])
|
| 214 |
+
|
| 215 |
+
const updatePreview = () => {
|
| 216 |
+
if (!previewRef.current) return
|
| 217 |
+
|
| 218 |
+
const htmlTab = tabs.find(t => t.id === 'html')
|
| 219 |
+
const cssTab = tabs.find(t => t.id === 'css')
|
| 220 |
+
const jsTab = tabs.find(t => t.id === 'js')
|
| 221 |
+
|
| 222 |
+
const previewContent = `
|
| 223 |
+
<!DOCTYPE html>
|
| 224 |
+
<html>
|
| 225 |
+
<head>
|
| 226 |
+
<style>${cssTab?.content || ''}</style>
|
| 227 |
+
</head>
|
| 228 |
+
<body>
|
| 229 |
+
${htmlTab?.content.replace(/<link.*?>/g, '').replace(/<script.*?src=.*?><\/script>/g, '') || ''}
|
| 230 |
+
<script>
|
| 231 |
+
const sessionId = '${sessionId}';
|
| 232 |
+
${jsTab?.content || ''}
|
| 233 |
+
</script>
|
| 234 |
+
</body>
|
| 235 |
+
</html>
|
| 236 |
+
`
|
| 237 |
+
|
| 238 |
+
const blob = new Blob([previewContent], { type: 'text/html' })
|
| 239 |
+
const url = URL.createObjectURL(blob)
|
| 240 |
+
previewRef.current.src = url
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
const handleEditorChange = (value: string | undefined) => {
|
| 244 |
+
if (!value) return
|
| 245 |
+
|
| 246 |
+
setTabs(prev => prev.map(tab =>
|
| 247 |
+
tab.id === activeTab
|
| 248 |
+
? { ...tab, content: value }
|
| 249 |
+
: tab
|
| 250 |
+
))
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
const downloadCode = () => {
|
| 254 |
+
const htmlTab = tabs.find(t => t.id === 'html')
|
| 255 |
+
const cssTab = tabs.find(t => t.id === 'css')
|
| 256 |
+
const jsTab = tabs.find(t => t.id === 'js')
|
| 257 |
+
|
| 258 |
+
const fullHTML = `<!DOCTYPE html>
|
| 259 |
+
<html lang="en">
|
| 260 |
+
<head>
|
| 261 |
+
<meta charset="UTF-8">
|
| 262 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 263 |
+
<title>My Reuben OS Project</title>
|
| 264 |
+
<style>
|
| 265 |
+
${cssTab?.content || ''}
|
| 266 |
+
</style>
|
| 267 |
+
</head>
|
| 268 |
+
<body>
|
| 269 |
+
${htmlTab?.content.replace(/<.*?html.*?>|<.*?head.*?>|<.*?body.*?>|<\/.*?html.*?>|<\/.*?head.*?>|<\/.*?body.*?>/gi, '').replace(/<link.*?>/g, '').replace(/<script.*?src=.*?><\/script>/g, '') || ''}
|
| 270 |
+
<script>
|
| 271 |
+
${jsTab?.content || ''}
|
| 272 |
+
</script>
|
| 273 |
+
</body>
|
| 274 |
+
</html>`
|
| 275 |
+
|
| 276 |
+
const blob = new Blob([fullHTML], { type: 'text/html' })
|
| 277 |
+
const url = URL.createObjectURL(blob)
|
| 278 |
+
const a = document.createElement('a')
|
| 279 |
+
a.href = url
|
| 280 |
+
a.download = `webos-project-${sessionId}.html`
|
| 281 |
+
a.click()
|
| 282 |
+
URL.revokeObjectURL(url)
|
| 283 |
+
}
|
| 284 |
+
|
| 285 |
+
const saveToMCP = async () => {
|
| 286 |
+
setIsSaving(true)
|
| 287 |
+
try {
|
| 288 |
+
const response = await fetch('/api/code/save', {
|
| 289 |
+
method: 'POST',
|
| 290 |
+
headers: { 'Content-Type': 'application/json' },
|
| 291 |
+
body: JSON.stringify({
|
| 292 |
+
sessionId,
|
| 293 |
+
code: tabs,
|
| 294 |
+
timestamp: Date.now()
|
| 295 |
+
})
|
| 296 |
+
})
|
| 297 |
+
|
| 298 |
+
if (response.ok) {
|
| 299 |
+
const result = await response.json()
|
| 300 |
+
console.log('Code saved successfully!', result)
|
| 301 |
+
|
| 302 |
+
// Show success message
|
| 303 |
+
const successDiv = document.createElement('div')
|
| 304 |
+
successDiv.className = 'fixed bottom-4 right-4 bg-green-600 text-white px-4 py-2 rounded-lg shadow-lg z-[200]'
|
| 305 |
+
successDiv.textContent = `Saved to: data/vscode_sessions/${sessionId}`
|
| 306 |
+
document.body.appendChild(successDiv)
|
| 307 |
+
|
| 308 |
+
setTimeout(() => {
|
| 309 |
+
successDiv.remove()
|
| 310 |
+
}, 3000)
|
| 311 |
+
|
| 312 |
+
// Also save to localStorage
|
| 313 |
+
localStorage.setItem(`vscode_${sessionId}`, JSON.stringify(tabs))
|
| 314 |
+
}
|
| 315 |
+
} catch (error) {
|
| 316 |
+
console.error('Failed to save code:', error)
|
| 317 |
+
|
| 318 |
+
// Show error message
|
| 319 |
+
const errorDiv = document.createElement('div')
|
| 320 |
+
errorDiv.className = 'fixed bottom-4 right-4 bg-red-600 text-white px-4 py-2 rounded-lg shadow-lg z-[200]'
|
| 321 |
+
errorDiv.textContent = 'Failed to save code'
|
| 322 |
+
document.body.appendChild(errorDiv)
|
| 323 |
+
|
| 324 |
+
setTimeout(() => {
|
| 325 |
+
errorDiv.remove()
|
| 326 |
+
}, 3000)
|
| 327 |
+
}
|
| 328 |
+
setIsSaving(false)
|
| 329 |
+
}
|
| 330 |
+
|
| 331 |
+
const activeTabContent = tabs.find(t => t.id === activeTab)
|
| 332 |
+
|
| 333 |
+
return (
|
| 334 |
+
<Window
|
| 335 |
+
id="vscode"
|
| 336 |
+
title="VS Code - Reuben OS Editor"
|
| 337 |
+
isOpen={true}
|
| 338 |
+
onClose={onClose}
|
| 339 |
+
width={isFullscreen ? window.innerWidth : 1400}
|
| 340 |
+
height={isFullscreen ? window.innerHeight - 32 : 800}
|
| 341 |
+
x={isFullscreen ? 0 : 50}
|
| 342 |
+
y={isFullscreen ? 32 : 50}
|
| 343 |
+
darkMode={true}
|
| 344 |
+
className="vscode-window"
|
| 345 |
+
>
|
| 346 |
+
<div className="flex flex-col h-full bg-[#1e1e1e]">
|
| 347 |
+
{/* Editor Header */}
|
| 348 |
+
<div className="flex items-center justify-between bg-[#2d2d2d] px-4 py-2 border-b border-[#3e3e3e]">
|
| 349 |
+
<div className="flex items-center gap-2">
|
| 350 |
+
<Code size={20} weight="bold" className="text-blue-400" />
|
| 351 |
+
<span className="text-gray-300 text-sm">Session: {sessionId.substring(0, 8)}...</span>
|
| 352 |
+
</div>
|
| 353 |
+
|
| 354 |
+
<div className="flex items-center gap-2">
|
| 355 |
+
<button
|
| 356 |
+
onClick={saveToMCP}
|
| 357 |
+
className="px-3 py-1 bg-green-600 hover:bg-green-700 text-white rounded text-sm flex items-center gap-2"
|
| 358 |
+
disabled={isSaving}
|
| 359 |
+
>
|
| 360 |
+
<FloppyDisk size={16} />
|
| 361 |
+
{isSaving ? 'Saving...' : 'Save'}
|
| 362 |
+
</button>
|
| 363 |
+
|
| 364 |
+
<button
|
| 365 |
+
onClick={downloadCode}
|
| 366 |
+
className="px-3 py-1 bg-blue-600 hover:bg-blue-700 text-white rounded text-sm flex items-center gap-2"
|
| 367 |
+
>
|
| 368 |
+
<Download size={16} />
|
| 369 |
+
Download
|
| 370 |
+
</button>
|
| 371 |
+
|
| 372 |
+
<button
|
| 373 |
+
onClick={() => setShowPreview(!showPreview)}
|
| 374 |
+
className="px-3 py-1 bg-gray-600 hover:bg-gray-700 text-white rounded text-sm flex items-center gap-2"
|
| 375 |
+
>
|
| 376 |
+
{showPreview ? <EyeSlash size={16} /> : <Eye size={16} />}
|
| 377 |
+
Preview
|
| 378 |
+
</button>
|
| 379 |
+
|
| 380 |
+
<button
|
| 381 |
+
onClick={() => setIsFullscreen(!isFullscreen)}
|
| 382 |
+
className="px-3 py-1 bg-gray-600 hover:bg-gray-700 text-white rounded text-sm flex items-center gap-2"
|
| 383 |
+
>
|
| 384 |
+
{isFullscreen ? <ArrowsInSimple size={16} /> : <ArrowsOutSimple size={16} />}
|
| 385 |
+
</button>
|
| 386 |
+
</div>
|
| 387 |
+
</div>
|
| 388 |
+
|
| 389 |
+
{/* Main Content */}
|
| 390 |
+
<div className="flex flex-1 overflow-hidden">
|
| 391 |
+
{/* Editor Section */}
|
| 392 |
+
<div className={`flex flex-col ${showPreview ? 'w-1/2' : 'w-full'} border-r border-[#3e3e3e]`}>
|
| 393 |
+
{/* Tabs */}
|
| 394 |
+
<div className="flex bg-[#252526] border-b border-[#3e3e3e]">
|
| 395 |
+
{tabs.map(tab => (
|
| 396 |
+
<button
|
| 397 |
+
key={tab.id}
|
| 398 |
+
onClick={() => setActiveTab(tab.id)}
|
| 399 |
+
className={`px-4 py-2 text-sm flex items-center gap-2 border-r border-[#3e3e3e] transition-colors ${
|
| 400 |
+
activeTab === tab.id
|
| 401 |
+
? 'bg-[#1e1e1e] text-white'
|
| 402 |
+
: 'text-gray-400 hover:text-white hover:bg-[#2d2d2d]'
|
| 403 |
+
}`}
|
| 404 |
+
>
|
| 405 |
+
{getTabIcon(tab.id)}
|
| 406 |
+
{tab.name}
|
| 407 |
+
</button>
|
| 408 |
+
))}
|
| 409 |
+
</div>
|
| 410 |
+
|
| 411 |
+
{/* Monaco Editor */}
|
| 412 |
+
<div className="flex-1">
|
| 413 |
+
<Editor
|
| 414 |
+
height="100%"
|
| 415 |
+
language={activeTabContent?.language}
|
| 416 |
+
value={activeTabContent?.content}
|
| 417 |
+
onChange={handleEditorChange}
|
| 418 |
+
theme="vs-dark"
|
| 419 |
+
options={{
|
| 420 |
+
minimap: { enabled: false },
|
| 421 |
+
fontSize: 14,
|
| 422 |
+
wordWrap: 'on',
|
| 423 |
+
automaticLayout: true,
|
| 424 |
+
scrollBeyondLastLine: false
|
| 425 |
+
}}
|
| 426 |
+
/>
|
| 427 |
+
</div>
|
| 428 |
+
</div>
|
| 429 |
+
|
| 430 |
+
{/* Preview Section */}
|
| 431 |
+
{showPreview && (
|
| 432 |
+
<div className="flex-1 flex flex-col bg-white">
|
| 433 |
+
<div className="bg-gray-100 px-4 py-2 border-b border-gray-300 flex items-center justify-between">
|
| 434 |
+
<div className="flex items-center gap-2">
|
| 435 |
+
<Eye size={16} className="text-gray-600" />
|
| 436 |
+
<span className="text-sm font-medium">Live Preview</span>
|
| 437 |
+
</div>
|
| 438 |
+
<button
|
| 439 |
+
onClick={updatePreview}
|
| 440 |
+
className="p-1 hover:bg-gray-200 rounded transition-colors"
|
| 441 |
+
>
|
| 442 |
+
<Play size={16} className="text-gray-600" />
|
| 443 |
+
</button>
|
| 444 |
+
</div>
|
| 445 |
+
|
| 446 |
+
<iframe
|
| 447 |
+
ref={previewRef}
|
| 448 |
+
className="flex-1 w-full bg-white"
|
| 449 |
+
sandbox="allow-scripts"
|
| 450 |
+
title="Code Preview"
|
| 451 |
+
/>
|
| 452 |
+
</div>
|
| 453 |
+
)}
|
| 454 |
+
</div>
|
| 455 |
+
</div>
|
| 456 |
+
</Window>
|
| 457 |
+
)
|
| 458 |
+
}
|
app/components/WebBrowser.tsx
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import React, { useState, useRef } from 'react'
|
| 4 |
+
import { Globe, RefreshCw, ArrowLeft, ArrowRight, Home, Lock, ExternalLink, X, Maximize2, Minimize2 } from 'lucide-react'
|
| 5 |
+
|
| 6 |
+
interface Tab {
|
| 7 |
+
id: string
|
| 8 |
+
url: string
|
| 9 |
+
title: string
|
| 10 |
+
isActive: boolean
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
export default function WebBrowser() {
|
| 14 |
+
const [tabs, setTabs] = useState<Tab[]>([
|
| 15 |
+
{ id: '1', url: 'https://www.google.com', title: 'Google', isActive: true }
|
| 16 |
+
])
|
| 17 |
+
const [currentUrl, setCurrentUrl] = useState('https://www.google.com')
|
| 18 |
+
const [inputUrl, setInputUrl] = useState('https://www.google.com')
|
| 19 |
+
const [isLoading, setIsLoading] = useState(false)
|
| 20 |
+
const [isFullscreen, setIsFullscreen] = useState(false)
|
| 21 |
+
const [showBrowser, setShowBrowser] = useState(false)
|
| 22 |
+
const iframeRef = useRef<HTMLIFrameElement>(null)
|
| 23 |
+
|
| 24 |
+
const handleNavigate = (url: string) => {
|
| 25 |
+
// Ensure URL has protocol
|
| 26 |
+
let finalUrl = url
|
| 27 |
+
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
| 28 |
+
finalUrl = 'https://' + url
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
setCurrentUrl(finalUrl)
|
| 32 |
+
setInputUrl(finalUrl)
|
| 33 |
+
setIsLoading(true)
|
| 34 |
+
|
| 35 |
+
// Update active tab
|
| 36 |
+
setTabs(prevTabs =>
|
| 37 |
+
prevTabs.map(tab =>
|
| 38 |
+
tab.isActive ? { ...tab, url: finalUrl } : tab
|
| 39 |
+
)
|
| 40 |
+
)
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
const handleRefresh = () => {
|
| 44 |
+
if (iframeRef.current) {
|
| 45 |
+
const currentSrc = iframeRef.current.src
|
| 46 |
+
iframeRef.current.src = ''
|
| 47 |
+
setTimeout(() => {
|
| 48 |
+
if (iframeRef.current) {
|
| 49 |
+
iframeRef.current.src = currentSrc
|
| 50 |
+
}
|
| 51 |
+
}, 10)
|
| 52 |
+
}
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
const handleBack = () => {
|
| 56 |
+
// Browser history would be handled by iframe internally
|
| 57 |
+
window.history.back()
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
const handleForward = () => {
|
| 61 |
+
window.history.forward()
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
const handleHome = () => {
|
| 65 |
+
handleNavigate('https://www.google.com')
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
const addNewTab = () => {
|
| 69 |
+
const newTab: Tab = {
|
| 70 |
+
id: Date.now().toString(),
|
| 71 |
+
url: 'https://www.google.com',
|
| 72 |
+
title: 'New Tab',
|
| 73 |
+
isActive: true
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
setTabs(prevTabs => [
|
| 77 |
+
...prevTabs.map(tab => ({ ...tab, isActive: false })),
|
| 78 |
+
newTab
|
| 79 |
+
])
|
| 80 |
+
setCurrentUrl(newTab.url)
|
| 81 |
+
setInputUrl(newTab.url)
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
const switchTab = (tabId: string) => {
|
| 85 |
+
const tab = tabs.find(t => t.id === tabId)
|
| 86 |
+
if (tab) {
|
| 87 |
+
setTabs(prevTabs =>
|
| 88 |
+
prevTabs.map(t => ({ ...t, isActive: t.id === tabId }))
|
| 89 |
+
)
|
| 90 |
+
setCurrentUrl(tab.url)
|
| 91 |
+
setInputUrl(tab.url)
|
| 92 |
+
}
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
const closeTab = (tabId: string) => {
|
| 96 |
+
if (tabs.length === 1) return // Don't close last tab
|
| 97 |
+
|
| 98 |
+
const tabIndex = tabs.findIndex(t => t.id === tabId)
|
| 99 |
+
const wasActive = tabs[tabIndex].isActive
|
| 100 |
+
|
| 101 |
+
const newTabs = tabs.filter(t => t.id !== tabId)
|
| 102 |
+
|
| 103 |
+
if (wasActive) {
|
| 104 |
+
const newActiveIndex = tabIndex > 0 ? tabIndex - 1 : 0
|
| 105 |
+
newTabs[newActiveIndex].isActive = true
|
| 106 |
+
setCurrentUrl(newTabs[newActiveIndex].url)
|
| 107 |
+
setInputUrl(newTabs[newActiveIndex].url)
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
setTabs(newTabs)
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
const openInNewWindow = () => {
|
| 114 |
+
window.open(currentUrl, '_blank')
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
if (!showBrowser) {
|
| 118 |
+
return (
|
| 119 |
+
<button
|
| 120 |
+
onClick={() => setShowBrowser(true)}
|
| 121 |
+
className="fixed bottom-4 right-4 z-50 px-4 py-2 bg-blue-600 text-white rounded-lg shadow-lg hover:bg-blue-700 transition-colors flex items-center gap-2"
|
| 122 |
+
>
|
| 123 |
+
<Globe size={20} />
|
| 124 |
+
Open Browser
|
| 125 |
+
</button>
|
| 126 |
+
)
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
return (
|
| 130 |
+
<div className={`fixed ${isFullscreen ? 'inset-0 z-[9999]' : 'inset-4 z-40'} bg-white rounded-lg shadow-2xl flex flex-col transition-all duration-300`}>
|
| 131 |
+
{/* Browser Header */}
|
| 132 |
+
<div className="bg-gray-100 border-b border-gray-300 rounded-t-lg">
|
| 133 |
+
{/* Tabs */}
|
| 134 |
+
<div className="flex items-center bg-gray-200 px-2 py-1 overflow-x-auto">
|
| 135 |
+
{tabs.map(tab => (
|
| 136 |
+
<div
|
| 137 |
+
key={tab.id}
|
| 138 |
+
className={`flex items-center px-3 py-1.5 mr-1 rounded-t-md cursor-pointer transition-colors ${
|
| 139 |
+
tab.isActive ? 'bg-white' : 'bg-gray-100 hover:bg-gray-50'
|
| 140 |
+
}`}
|
| 141 |
+
onClick={() => switchTab(tab.id)}
|
| 142 |
+
>
|
| 143 |
+
<Globe size={14} className="mr-2 text-gray-600" />
|
| 144 |
+
<span className="text-sm max-w-[150px] truncate">{tab.title}</span>
|
| 145 |
+
<button
|
| 146 |
+
onClick={(e) => {
|
| 147 |
+
e.stopPropagation()
|
| 148 |
+
closeTab(tab.id)
|
| 149 |
+
}}
|
| 150 |
+
className="ml-2 p-0.5 hover:bg-gray-200 rounded"
|
| 151 |
+
>
|
| 152 |
+
<X size={14} />
|
| 153 |
+
</button>
|
| 154 |
+
</div>
|
| 155 |
+
))}
|
| 156 |
+
<button
|
| 157 |
+
onClick={addNewTab}
|
| 158 |
+
className="px-2 py-1 text-gray-600 hover:bg-gray-200 rounded"
|
| 159 |
+
>
|
| 160 |
+
+
|
| 161 |
+
</button>
|
| 162 |
+
</div>
|
| 163 |
+
|
| 164 |
+
{/* Navigation Bar */}
|
| 165 |
+
<div className="flex items-center gap-2 p-2 bg-white">
|
| 166 |
+
<button
|
| 167 |
+
onClick={handleBack}
|
| 168 |
+
className="p-2 hover:bg-gray-100 rounded transition-colors"
|
| 169 |
+
title="Back"
|
| 170 |
+
>
|
| 171 |
+
<ArrowLeft size={18} />
|
| 172 |
+
</button>
|
| 173 |
+
<button
|
| 174 |
+
onClick={handleForward}
|
| 175 |
+
className="p-2 hover:bg-gray-100 rounded transition-colors"
|
| 176 |
+
title="Forward"
|
| 177 |
+
>
|
| 178 |
+
<ArrowRight size={18} />
|
| 179 |
+
</button>
|
| 180 |
+
<button
|
| 181 |
+
onClick={handleRefresh}
|
| 182 |
+
className="p-2 hover:bg-gray-100 rounded transition-colors"
|
| 183 |
+
title="Refresh"
|
| 184 |
+
>
|
| 185 |
+
<RefreshCw size={18} className={isLoading ? 'animate-spin' : ''} />
|
| 186 |
+
</button>
|
| 187 |
+
<button
|
| 188 |
+
onClick={handleHome}
|
| 189 |
+
className="p-2 hover:bg-gray-100 rounded transition-colors"
|
| 190 |
+
title="Home"
|
| 191 |
+
>
|
| 192 |
+
<Home size={18} />
|
| 193 |
+
</button>
|
| 194 |
+
|
| 195 |
+
{/* URL Bar */}
|
| 196 |
+
<div className="flex-1 flex items-center bg-gray-50 rounded-md px-3 py-1.5 border border-gray-300">
|
| 197 |
+
<Lock size={14} className="text-green-600 mr-2" />
|
| 198 |
+
<input
|
| 199 |
+
type="text"
|
| 200 |
+
value={inputUrl}
|
| 201 |
+
onChange={(e) => setInputUrl(e.target.value)}
|
| 202 |
+
onKeyPress={(e) => {
|
| 203 |
+
if (e.key === 'Enter') {
|
| 204 |
+
handleNavigate(inputUrl)
|
| 205 |
+
}
|
| 206 |
+
}}
|
| 207 |
+
className="flex-1 bg-transparent outline-none text-sm"
|
| 208 |
+
placeholder="Enter URL..."
|
| 209 |
+
/>
|
| 210 |
+
</div>
|
| 211 |
+
|
| 212 |
+
<button
|
| 213 |
+
onClick={openInNewWindow}
|
| 214 |
+
className="p-2 hover:bg-gray-100 rounded transition-colors"
|
| 215 |
+
title="Open in New Window"
|
| 216 |
+
>
|
| 217 |
+
<ExternalLink size={18} />
|
| 218 |
+
</button>
|
| 219 |
+
<button
|
| 220 |
+
onClick={() => setIsFullscreen(!isFullscreen)}
|
| 221 |
+
className="p-2 hover:bg-gray-100 rounded transition-colors"
|
| 222 |
+
title={isFullscreen ? 'Exit Fullscreen' : 'Fullscreen'}
|
| 223 |
+
>
|
| 224 |
+
{isFullscreen ? <Minimize2 size={18} /> : <Maximize2 size={18} />}
|
| 225 |
+
</button>
|
| 226 |
+
<button
|
| 227 |
+
onClick={() => setShowBrowser(false)}
|
| 228 |
+
className="p-2 hover:bg-gray-100 rounded transition-colors text-red-600"
|
| 229 |
+
title="Close Browser"
|
| 230 |
+
>
|
| 231 |
+
<X size={18} />
|
| 232 |
+
</button>
|
| 233 |
+
</div>
|
| 234 |
+
</div>
|
| 235 |
+
|
| 236 |
+
{/* Browser Content */}
|
| 237 |
+
<div className="flex-1 relative bg-white">
|
| 238 |
+
{/* Loading Indicator */}
|
| 239 |
+
{isLoading && (
|
| 240 |
+
<div className="absolute top-0 left-0 w-full h-1 bg-blue-600 animate-pulse z-10" />
|
| 241 |
+
)}
|
| 242 |
+
|
| 243 |
+
{/* iframe Content */}
|
| 244 |
+
<iframe
|
| 245 |
+
ref={iframeRef}
|
| 246 |
+
src={currentUrl}
|
| 247 |
+
className="w-full h-full border-0"
|
| 248 |
+
onLoad={() => {
|
| 249 |
+
setIsLoading(false)
|
| 250 |
+
// Try to get the title from the iframe (may be blocked by CORS)
|
| 251 |
+
try {
|
| 252 |
+
const title = iframeRef.current?.contentDocument?.title || 'Web Page'
|
| 253 |
+
setTabs(prevTabs =>
|
| 254 |
+
prevTabs.map(tab =>
|
| 255 |
+
tab.isActive ? { ...tab, title } : tab
|
| 256 |
+
)
|
| 257 |
+
)
|
| 258 |
+
} catch (e) {
|
| 259 |
+
// CORS will block this for most external sites
|
| 260 |
+
console.log('Could not access iframe title due to CORS')
|
| 261 |
+
}
|
| 262 |
+
}}
|
| 263 |
+
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
|
| 264 |
+
title="Web Browser"
|
| 265 |
+
/>
|
| 266 |
+
|
| 267 |
+
{/* CORS Notice */}
|
| 268 |
+
<div className="absolute bottom-4 right-4 bg-yellow-100 border border-yellow-400 rounded-lg p-3 max-w-sm">
|
| 269 |
+
<p className="text-xs text-yellow-800">
|
| 270 |
+
<strong>Note:</strong> Some websites may not display due to security restrictions (CORS).
|
| 271 |
+
For full browsing, consider using a proxy service or browser extension.
|
| 272 |
+
</p>
|
| 273 |
+
</div>
|
| 274 |
+
</div>
|
| 275 |
+
</div>
|
| 276 |
+
)
|
| 277 |
+
}
|
app/components/WebBrowserApp.tsx
ADDED
|
@@ -0,0 +1,438 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import React, { useState, useRef } from 'react'
|
| 4 |
+
import { Globe, RefreshCw, ArrowLeft, ArrowRight, Home, Lock, ExternalLink, AlertTriangle, Shield, X } from 'lucide-react'
|
| 5 |
+
import Window from './Window'
|
| 6 |
+
|
| 7 |
+
interface Tab {
|
| 8 |
+
id: string
|
| 9 |
+
url: string
|
| 10 |
+
title: string
|
| 11 |
+
isActive: boolean
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
interface WebBrowserAppProps {
|
| 15 |
+
onClose: () => void
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
export default function WebBrowserApp({ onClose }: WebBrowserAppProps) {
|
| 19 |
+
const [tabs, setTabs] = useState<Tab[]>([
|
| 20 |
+
{ id: '1', url: 'https://www.google.com/webhp?igu=1', title: 'Google', isActive: true }
|
| 21 |
+
])
|
| 22 |
+
const [currentUrl, setCurrentUrl] = useState('https://www.google.com/webhp?igu=1')
|
| 23 |
+
const [inputUrl, setInputUrl] = useState('https://www.google.com')
|
| 24 |
+
const [isLoading, setIsLoading] = useState(false)
|
| 25 |
+
const [isMaximized, setIsMaximized] = useState(false)
|
| 26 |
+
const [useProxy, setUseProxy] = useState(false)
|
| 27 |
+
const [showSettings, setShowSettings] = useState(false)
|
| 28 |
+
const [showHomePage, setShowHomePage] = useState(true)
|
| 29 |
+
const iframeRef = useRef<HTMLIFrameElement>(null)
|
| 30 |
+
|
| 31 |
+
// List of sites that work well in iframes
|
| 32 |
+
const iframeFriendlySites = [
|
| 33 |
+
{ name: 'Google', url: 'https://www.google.com/webhp?igu=1' },
|
| 34 |
+
{ name: 'Wikipedia', url: 'https://www.wikipedia.org' },
|
| 35 |
+
{ name: 'MDN Web Docs', url: 'https://developer.mozilla.org' },
|
| 36 |
+
{ name: 'Stack Overflow', url: 'https://stackoverflow.com' },
|
| 37 |
+
{ name: 'GitHub', url: 'https://github.com' },
|
| 38 |
+
{ name: 'DuckDuckGo', url: 'https://duckduckgo.com' }
|
| 39 |
+
]
|
| 40 |
+
|
| 41 |
+
const getProxiedUrl = (url: string) => {
|
| 42 |
+
// Use a CORS proxy service - you can replace this with your own proxy
|
| 43 |
+
if (useProxy) {
|
| 44 |
+
// Option 1: Use allorigins proxy (free, public) - SLOW but works
|
| 45 |
+
return `https://api.allorigins.win/raw?url=${encodeURIComponent(url)}`
|
| 46 |
+
|
| 47 |
+
// Option 2: Use cors-anywhere (may require your own deployment)
|
| 48 |
+
// return `https://cors-anywhere.herokuapp.com/${url}`
|
| 49 |
+
|
| 50 |
+
// Option 3: Use your own backend proxy endpoint
|
| 51 |
+
// return `/api/proxy?url=${encodeURIComponent(url)}`
|
| 52 |
+
}
|
| 53 |
+
return url
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
const handleNavigate = (url: string) => {
|
| 57 |
+
// Ensure URL has protocol
|
| 58 |
+
let finalUrl = url
|
| 59 |
+
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
| 60 |
+
// Check if it looks like a search query
|
| 61 |
+
if (!url.includes('.') || url.includes(' ')) {
|
| 62 |
+
finalUrl = `https://www.google.com/search?q=${encodeURIComponent(url)}&igu=1`
|
| 63 |
+
} else {
|
| 64 |
+
finalUrl = 'https://' + url
|
| 65 |
+
}
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
setShowHomePage(false)
|
| 69 |
+
const proxiedUrl = getProxiedUrl(finalUrl)
|
| 70 |
+
setCurrentUrl(proxiedUrl)
|
| 71 |
+
setInputUrl(finalUrl)
|
| 72 |
+
setIsLoading(true)
|
| 73 |
+
|
| 74 |
+
// Update active tab
|
| 75 |
+
setTabs(prevTabs =>
|
| 76 |
+
prevTabs.map(tab =>
|
| 77 |
+
tab.isActive ? { ...tab, url: finalUrl, title: new URL(finalUrl).hostname } : tab
|
| 78 |
+
)
|
| 79 |
+
)
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
const handleRefresh = () => {
|
| 83 |
+
if (iframeRef.current) {
|
| 84 |
+
setIsLoading(true)
|
| 85 |
+
const currentSrc = iframeRef.current.src
|
| 86 |
+
iframeRef.current.src = ''
|
| 87 |
+
setTimeout(() => {
|
| 88 |
+
if (iframeRef.current) {
|
| 89 |
+
iframeRef.current.src = currentSrc
|
| 90 |
+
}
|
| 91 |
+
}, 10)
|
| 92 |
+
}
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
const handleBack = () => {
|
| 96 |
+
window.history.back()
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
const handleForward = () => {
|
| 100 |
+
window.history.forward()
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
const handleHome = () => {
|
| 104 |
+
setShowHomePage(true)
|
| 105 |
+
setCurrentUrl('')
|
| 106 |
+
setInputUrl('')
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
const addNewTab = () => {
|
| 110 |
+
const newTab: Tab = {
|
| 111 |
+
id: Date.now().toString(),
|
| 112 |
+
url: 'https://www.google.com/webhp?igu=1',
|
| 113 |
+
title: 'New Tab',
|
| 114 |
+
isActive: true
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
setTabs(prevTabs => [
|
| 118 |
+
...prevTabs.map(tab => ({ ...tab, isActive: false })),
|
| 119 |
+
newTab
|
| 120 |
+
])
|
| 121 |
+
handleNavigate(newTab.url)
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
const switchTab = (tabId: string) => {
|
| 125 |
+
const tab = tabs.find(t => t.id === tabId)
|
| 126 |
+
if (tab) {
|
| 127 |
+
setTabs(prevTabs =>
|
| 128 |
+
prevTabs.map(t => ({ ...t, isActive: t.id === tabId }))
|
| 129 |
+
)
|
| 130 |
+
handleNavigate(tab.url)
|
| 131 |
+
}
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
const closeTab = (tabId: string, e: React.MouseEvent) => {
|
| 135 |
+
e.stopPropagation()
|
| 136 |
+
if (tabs.length === 1) return
|
| 137 |
+
|
| 138 |
+
const tabIndex = tabs.findIndex(t => t.id === tabId)
|
| 139 |
+
const wasActive = tabs[tabIndex].isActive
|
| 140 |
+
|
| 141 |
+
const newTabs = tabs.filter(t => t.id !== tabId)
|
| 142 |
+
|
| 143 |
+
if (wasActive) {
|
| 144 |
+
const newActiveIndex = tabIndex > 0 ? tabIndex - 1 : 0
|
| 145 |
+
newTabs[newActiveIndex].isActive = true
|
| 146 |
+
handleNavigate(newTabs[newActiveIndex].url)
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
setTabs(newTabs)
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
const openInNewWindow = () => {
|
| 153 |
+
const activeTab = tabs.find(t => t.isActive)
|
| 154 |
+
if (activeTab) {
|
| 155 |
+
window.open(activeTab.url, '_blank')
|
| 156 |
+
}
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
return (
|
| 160 |
+
<Window
|
| 161 |
+
id="browser"
|
| 162 |
+
title="Web Browser"
|
| 163 |
+
isOpen={true}
|
| 164 |
+
onClose={onClose}
|
| 165 |
+
width={1200}
|
| 166 |
+
height={700}
|
| 167 |
+
x={window.innerWidth / 2 - 600}
|
| 168 |
+
y={window.innerHeight / 2 - 350}
|
| 169 |
+
className="browser-window"
|
| 170 |
+
>
|
| 171 |
+
{/* Browser Header */}
|
| 172 |
+
<div className="bg-gray-100 border-b border-gray-300">
|
| 173 |
+
{/* Tabs */}
|
| 174 |
+
<div className="flex items-center bg-gray-200 px-2 py-1 overflow-x-auto">
|
| 175 |
+
{tabs.map(tab => (
|
| 176 |
+
<div
|
| 177 |
+
key={tab.id}
|
| 178 |
+
className={`flex items-center px-3 py-1.5 mr-1 rounded-t-md cursor-pointer transition-colors min-w-[150px] max-w-[200px] ${
|
| 179 |
+
tab.isActive ? 'bg-white' : 'bg-gray-100 hover:bg-gray-50'
|
| 180 |
+
}`}
|
| 181 |
+
onClick={() => switchTab(tab.id)}
|
| 182 |
+
>
|
| 183 |
+
<Globe size={14} className="mr-2 text-gray-600 flex-shrink-0" />
|
| 184 |
+
<span className="text-sm truncate flex-1">{tab.title}</span>
|
| 185 |
+
{tabs.length > 1 && (
|
| 186 |
+
<button
|
| 187 |
+
onClick={(e) => closeTab(tab.id, e)}
|
| 188 |
+
className="ml-2 p-0.5 hover:bg-gray-200 rounded flex-shrink-0"
|
| 189 |
+
>
|
| 190 |
+
<X size={14} />
|
| 191 |
+
</button>
|
| 192 |
+
)}
|
| 193 |
+
</div>
|
| 194 |
+
))}
|
| 195 |
+
<button
|
| 196 |
+
onClick={addNewTab}
|
| 197 |
+
className="px-2 py-1 text-gray-600 hover:bg-gray-200 rounded"
|
| 198 |
+
>
|
| 199 |
+
+
|
| 200 |
+
</button>
|
| 201 |
+
</div>
|
| 202 |
+
|
| 203 |
+
{/* Navigation Bar */}
|
| 204 |
+
<div className="flex items-center gap-2 p-2 bg-white">
|
| 205 |
+
<button
|
| 206 |
+
onClick={handleBack}
|
| 207 |
+
className="p-2 hover:bg-gray-100 rounded transition-colors"
|
| 208 |
+
title="Back"
|
| 209 |
+
>
|
| 210 |
+
<ArrowLeft size={18} />
|
| 211 |
+
</button>
|
| 212 |
+
<button
|
| 213 |
+
onClick={handleForward}
|
| 214 |
+
className="p-2 hover:bg-gray-100 rounded transition-colors"
|
| 215 |
+
title="Forward"
|
| 216 |
+
>
|
| 217 |
+
<ArrowRight size={18} />
|
| 218 |
+
</button>
|
| 219 |
+
<button
|
| 220 |
+
onClick={handleRefresh}
|
| 221 |
+
className="p-2 hover:bg-gray-100 rounded transition-colors"
|
| 222 |
+
title="Refresh"
|
| 223 |
+
>
|
| 224 |
+
<RefreshCw size={18} className={isLoading ? 'animate-spin' : ''} />
|
| 225 |
+
</button>
|
| 226 |
+
<button
|
| 227 |
+
onClick={handleHome}
|
| 228 |
+
className="p-2 hover:bg-gray-100 rounded transition-colors"
|
| 229 |
+
title="Home"
|
| 230 |
+
>
|
| 231 |
+
<Home size={18} />
|
| 232 |
+
</button>
|
| 233 |
+
|
| 234 |
+
{/* URL Bar */}
|
| 235 |
+
<div className="flex-1 flex items-center bg-gray-50 rounded-md px-3 py-1.5 border border-gray-300">
|
| 236 |
+
{useProxy ? (
|
| 237 |
+
<Shield size={14} className="text-green-600 mr-2" />
|
| 238 |
+
) : (
|
| 239 |
+
<Lock size={14} className="text-gray-400 mr-2" />
|
| 240 |
+
)}
|
| 241 |
+
<input
|
| 242 |
+
type="text"
|
| 243 |
+
value={inputUrl}
|
| 244 |
+
onChange={(e) => setInputUrl(e.target.value)}
|
| 245 |
+
onKeyPress={(e) => {
|
| 246 |
+
if (e.key === 'Enter') {
|
| 247 |
+
handleNavigate(inputUrl)
|
| 248 |
+
}
|
| 249 |
+
}}
|
| 250 |
+
className="flex-1 bg-transparent outline-none text-sm"
|
| 251 |
+
placeholder="Enter URL or search..."
|
| 252 |
+
/>
|
| 253 |
+
</div>
|
| 254 |
+
|
| 255 |
+
<button
|
| 256 |
+
onClick={() => setShowSettings(!showSettings)}
|
| 257 |
+
className={`p-2 rounded transition-colors ${showSettings ? 'bg-blue-100' : 'hover:bg-gray-100'}`}
|
| 258 |
+
title="Settings"
|
| 259 |
+
>
|
| 260 |
+
<Shield size={18} className={useProxy ? 'text-green-600' : 'text-gray-400'} />
|
| 261 |
+
</button>
|
| 262 |
+
<button
|
| 263 |
+
onClick={openInNewWindow}
|
| 264 |
+
className="p-2 hover:bg-gray-100 rounded transition-colors"
|
| 265 |
+
title="Open in New Window"
|
| 266 |
+
>
|
| 267 |
+
<ExternalLink size={18} />
|
| 268 |
+
</button>
|
| 269 |
+
</div>
|
| 270 |
+
|
| 271 |
+
{/* Settings Panel */}
|
| 272 |
+
{showSettings && (
|
| 273 |
+
<div className="px-4 py-3 bg-blue-50 border-b border-blue-200">
|
| 274 |
+
<div className="flex items-center justify-between mb-2">
|
| 275 |
+
<label className="flex items-center gap-2 cursor-pointer">
|
| 276 |
+
<input
|
| 277 |
+
type="checkbox"
|
| 278 |
+
checked={useProxy}
|
| 279 |
+
onChange={(e) => setUseProxy(e.target.checked)}
|
| 280 |
+
className="w-4 h-4"
|
| 281 |
+
/>
|
| 282 |
+
<span className="text-sm font-medium">Use CORS Proxy</span>
|
| 283 |
+
<span className="text-xs text-gray-600">(Bypass cross-origin restrictions)</span>
|
| 284 |
+
</label>
|
| 285 |
+
</div>
|
| 286 |
+
<div className="text-xs text-gray-600">
|
| 287 |
+
<p className="mb-1">• Proxy allows accessing most websites but may affect performance</p>
|
| 288 |
+
<p>• Some interactive features may not work through proxy</p>
|
| 289 |
+
</div>
|
| 290 |
+
</div>
|
| 291 |
+
)}
|
| 292 |
+
|
| 293 |
+
{/* Quick Links */}
|
| 294 |
+
<div className="px-4 py-2 bg-gray-50 border-b flex items-center gap-2 overflow-x-auto">
|
| 295 |
+
<span className="text-xs text-gray-600 mr-2">Quick Links:</span>
|
| 296 |
+
{iframeFriendlySites.map(site => (
|
| 297 |
+
<button
|
| 298 |
+
key={site.name}
|
| 299 |
+
onClick={() => handleNavigate(site.url)}
|
| 300 |
+
className="px-3 py-1 text-xs bg-white border border-gray-300 rounded hover:bg-blue-50 hover:border-blue-400 transition-colors whitespace-nowrap"
|
| 301 |
+
>
|
| 302 |
+
{site.name}
|
| 303 |
+
</button>
|
| 304 |
+
))}
|
| 305 |
+
</div>
|
| 306 |
+
</div>
|
| 307 |
+
|
| 308 |
+
{/* Browser Content */}
|
| 309 |
+
<div className="flex-1 relative bg-white overflow-hidden">
|
| 310 |
+
{/* Loading Indicator */}
|
| 311 |
+
{isLoading && !showHomePage && (
|
| 312 |
+
<div className="absolute top-0 left-0 w-full h-1 bg-blue-600 z-20">
|
| 313 |
+
<div className="h-full bg-blue-400 animate-pulse" />
|
| 314 |
+
</div>
|
| 315 |
+
)}
|
| 316 |
+
|
| 317 |
+
{/* Home Page */}
|
| 318 |
+
{showHomePage ? (
|
| 319 |
+
<div className="w-full h-full flex flex-col items-center justify-center bg-gradient-to-br from-blue-50 to-purple-50 p-8">
|
| 320 |
+
<div className="max-w-2xl w-full space-y-8">
|
| 321 |
+
{/* Browser Logo */}
|
| 322 |
+
<div className="flex flex-col items-center gap-4">
|
| 323 |
+
<div className="w-24 h-24 rounded-full bg-gradient-to-br from-blue-500 to-cyan-500 flex items-center justify-center shadow-lg">
|
| 324 |
+
<Globe size={48} className="text-white" />
|
| 325 |
+
</div>
|
| 326 |
+
<h1 className="text-4xl font-bold text-gray-800">Web Browser</h1>
|
| 327 |
+
<p className="text-gray-600 text-center">Browse the web with speed and simplicity</p>
|
| 328 |
+
</div>
|
| 329 |
+
|
| 330 |
+
{/* Search Box */}
|
| 331 |
+
<div className="w-full">
|
| 332 |
+
<div className="relative">
|
| 333 |
+
<input
|
| 334 |
+
type="text"
|
| 335 |
+
placeholder="Search or enter website address..."
|
| 336 |
+
className="w-full px-6 py-4 text-lg rounded-full border-2 border-gray-300 focus:border-blue-500 focus:outline-none shadow-md"
|
| 337 |
+
onKeyPress={(e) => {
|
| 338 |
+
if (e.key === 'Enter') {
|
| 339 |
+
const value = (e.target as HTMLInputElement).value
|
| 340 |
+
if (value.trim()) {
|
| 341 |
+
handleNavigate(value)
|
| 342 |
+
}
|
| 343 |
+
}
|
| 344 |
+
}}
|
| 345 |
+
/>
|
| 346 |
+
<div className="absolute right-4 top-1/2 -translate-y-1/2 text-gray-400">
|
| 347 |
+
Press Enter ↵
|
| 348 |
+
</div>
|
| 349 |
+
</div>
|
| 350 |
+
</div>
|
| 351 |
+
|
| 352 |
+
{/* Quick Access Grid */}
|
| 353 |
+
<div>
|
| 354 |
+
<h2 className="text-sm font-medium text-gray-600 mb-3">Quick Access</h2>
|
| 355 |
+
<div className="grid grid-cols-3 gap-4">
|
| 356 |
+
{iframeFriendlySites.map(site => (
|
| 357 |
+
<button
|
| 358 |
+
key={site.name}
|
| 359 |
+
onClick={() => handleNavigate(site.url)}
|
| 360 |
+
className="p-4 bg-white rounded-lg shadow hover:shadow-md transition-shadow border border-gray-200 hover:border-blue-400 group"
|
| 361 |
+
>
|
| 362 |
+
<div className="flex flex-col items-center gap-2">
|
| 363 |
+
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-blue-100 to-purple-100 flex items-center justify-center group-hover:from-blue-200 group-hover:to-purple-200 transition-colors">
|
| 364 |
+
<Globe size={24} className="text-blue-600" />
|
| 365 |
+
</div>
|
| 366 |
+
<span className="text-sm font-medium text-gray-700">{site.name}</span>
|
| 367 |
+
</div>
|
| 368 |
+
</button>
|
| 369 |
+
))}
|
| 370 |
+
</div>
|
| 371 |
+
</div>
|
| 372 |
+
|
| 373 |
+
{/* Performance Tip */}
|
| 374 |
+
<div className="bg-blue-100 border border-blue-300 rounded-lg p-4">
|
| 375 |
+
<div className="flex items-start gap-3">
|
| 376 |
+
<Shield size={20} className="text-blue-600 mt-0.5 flex-shrink-0" />
|
| 377 |
+
<div className="text-sm">
|
| 378 |
+
<p className="text-blue-900 font-medium mb-1">⚡ Fast Direct Access</p>
|
| 379 |
+
<p className="text-blue-800">
|
| 380 |
+
For best performance, websites are loaded directly without proxy. Some sites may block iframe access due to security policies.
|
| 381 |
+
</p>
|
| 382 |
+
</div>
|
| 383 |
+
</div>
|
| 384 |
+
</div>
|
| 385 |
+
</div>
|
| 386 |
+
</div>
|
| 387 |
+
) : (
|
| 388 |
+
<>
|
| 389 |
+
{/* iframe Content */}
|
| 390 |
+
<iframe
|
| 391 |
+
ref={iframeRef}
|
| 392 |
+
src={currentUrl}
|
| 393 |
+
className="w-full h-full border-0"
|
| 394 |
+
onLoad={() => {
|
| 395 |
+
setIsLoading(false)
|
| 396 |
+
// Try to get the title (may be blocked by CORS)
|
| 397 |
+
try {
|
| 398 |
+
if (iframeRef.current?.contentDocument?.title) {
|
| 399 |
+
const title = iframeRef.current.contentDocument.title
|
| 400 |
+
setTabs(prevTabs =>
|
| 401 |
+
prevTabs.map(tab =>
|
| 402 |
+
tab.isActive ? { ...tab, title } : tab
|
| 403 |
+
)
|
| 404 |
+
)
|
| 405 |
+
}
|
| 406 |
+
} catch (e) {
|
| 407 |
+
// Expected for cross-origin content
|
| 408 |
+
}
|
| 409 |
+
}}
|
| 410 |
+
onError={() => {
|
| 411 |
+
setIsLoading(false)
|
| 412 |
+
}}
|
| 413 |
+
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox"
|
| 414 |
+
title="Web Browser"
|
| 415 |
+
/>
|
| 416 |
+
|
| 417 |
+
{/* Info Panel - only show if using proxy */}
|
| 418 |
+
{useProxy && (
|
| 419 |
+
<div className="absolute bottom-4 right-4 bg-yellow-100 border border-yellow-400 rounded-lg p-3 max-w-sm z-10">
|
| 420 |
+
<div className="flex items-start gap-2">
|
| 421 |
+
<AlertTriangle size={16} className="text-yellow-700 mt-0.5" />
|
| 422 |
+
<div>
|
| 423 |
+
<p className="text-xs text-yellow-800 font-medium mb-1">
|
| 424 |
+
Using Proxy (Slower)
|
| 425 |
+
</p>
|
| 426 |
+
<p className="text-xs text-yellow-700">
|
| 427 |
+
Proxy adds extra latency. Disable in settings for faster loading.
|
| 428 |
+
</p>
|
| 429 |
+
</div>
|
| 430 |
+
</div>
|
| 431 |
+
</div>
|
| 432 |
+
)}
|
| 433 |
+
</>
|
| 434 |
+
)}
|
| 435 |
+
</div>
|
| 436 |
+
</Window>
|
| 437 |
+
)
|
| 438 |
+
}
|
app/components/Window.tsx
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import React, { ReactNode } from 'react';
|
| 4 |
+
import { Rnd } from 'react-rnd';
|
| 5 |
+
|
| 6 |
+
interface WindowProps {
|
| 7 |
+
id: string;
|
| 8 |
+
title: string;
|
| 9 |
+
isOpen: boolean;
|
| 10 |
+
onClose: () => void;
|
| 11 |
+
onMinimize?: () => void;
|
| 12 |
+
onMaximize?: () => void;
|
| 13 |
+
children: ReactNode;
|
| 14 |
+
width?: number | string;
|
| 15 |
+
height?: number | string;
|
| 16 |
+
x?: number;
|
| 17 |
+
y?: number;
|
| 18 |
+
resizable?: boolean;
|
| 19 |
+
className?: string;
|
| 20 |
+
headerClassName?: string;
|
| 21 |
+
darkMode?: boolean;
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
const Window: React.FC<WindowProps> = ({
|
| 25 |
+
id,
|
| 26 |
+
title,
|
| 27 |
+
isOpen,
|
| 28 |
+
onClose,
|
| 29 |
+
onMinimize,
|
| 30 |
+
onMaximize,
|
| 31 |
+
children,
|
| 32 |
+
width = 800,
|
| 33 |
+
height = 600,
|
| 34 |
+
x = 100,
|
| 35 |
+
y = 100,
|
| 36 |
+
resizable = true,
|
| 37 |
+
className = '',
|
| 38 |
+
headerClassName = '',
|
| 39 |
+
darkMode = false,
|
| 40 |
+
}) => {
|
| 41 |
+
const [isMaximized, setIsMaximized] = React.useState(false);
|
| 42 |
+
const [previousSize, setPreviousSize] = React.useState({ width, height, x, y });
|
| 43 |
+
const [currentPosition, setCurrentPosition] = React.useState({ x, y });
|
| 44 |
+
const [currentSize, setCurrentSize] = React.useState({ width, height });
|
| 45 |
+
|
| 46 |
+
if (!isOpen) return null;
|
| 47 |
+
|
| 48 |
+
const handleMaximize = () => {
|
| 49 |
+
if (!onMaximize) return;
|
| 50 |
+
|
| 51 |
+
if (!isMaximized) {
|
| 52 |
+
setPreviousSize({ width, height, x, y });
|
| 53 |
+
setIsMaximized(true);
|
| 54 |
+
} else {
|
| 55 |
+
setIsMaximized(false);
|
| 56 |
+
}
|
| 57 |
+
onMaximize();
|
| 58 |
+
};
|
| 59 |
+
|
| 60 |
+
const windowClass = darkMode ? 'bg-gray-900 border-gray-700' : 'bg-[#f5f5f5] border-gray-300/50';
|
| 61 |
+
const headerClass = darkMode ? 'bg-gray-800 border-gray-700' : 'bg-[#f1f1f1] border-gray-300';
|
| 62 |
+
|
| 63 |
+
if (isMaximized) {
|
| 64 |
+
return (
|
| 65 |
+
<div
|
| 66 |
+
className={`fixed inset-0 top-8 z-50 flex flex-col overflow-hidden ${windowClass} ${className}`}
|
| 67 |
+
>
|
| 68 |
+
<div
|
| 69 |
+
className={`h-12 flex items-center px-4 space-x-4 border-b ${headerClass} ${headerClassName}`}
|
| 70 |
+
>
|
| 71 |
+
<div className="flex space-x-2">
|
| 72 |
+
<div className="traffic-light traffic-close" onClick={onClose} />
|
| 73 |
+
<div className="traffic-light traffic-min" onClick={onMinimize} />
|
| 74 |
+
<div className="traffic-light traffic-max" onClick={handleMaximize} />
|
| 75 |
+
</div>
|
| 76 |
+
<span className="font-semibold text-gray-700 flex-1 text-center pr-16">{title}</span>
|
| 77 |
+
</div>
|
| 78 |
+
<div className="flex-1 overflow-auto">{children}</div>
|
| 79 |
+
</div>
|
| 80 |
+
);
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
return (
|
| 84 |
+
<Rnd
|
| 85 |
+
default={{
|
| 86 |
+
x: currentPosition.x,
|
| 87 |
+
y: currentPosition.y,
|
| 88 |
+
width: typeof currentSize.width === 'number' ? currentSize.width : 800,
|
| 89 |
+
height: typeof currentSize.height === 'number' ? currentSize.height : 600,
|
| 90 |
+
}}
|
| 91 |
+
position={undefined}
|
| 92 |
+
size={undefined}
|
| 93 |
+
minWidth={400}
|
| 94 |
+
minHeight={300}
|
| 95 |
+
bounds="parent"
|
| 96 |
+
dragHandleClassName="window-drag-handle"
|
| 97 |
+
enableResizing={resizable}
|
| 98 |
+
onDragStop={(e, d) => {
|
| 99 |
+
setCurrentPosition({ x: d.x, y: d.y });
|
| 100 |
+
}}
|
| 101 |
+
onResizeStop={(e, direction, ref, delta, position) => {
|
| 102 |
+
setCurrentSize({
|
| 103 |
+
width: ref.offsetWidth,
|
| 104 |
+
height: ref.offsetHeight,
|
| 105 |
+
});
|
| 106 |
+
setCurrentPosition(position);
|
| 107 |
+
}}
|
| 108 |
+
className="window-transition"
|
| 109 |
+
style={{ zIndex: 50 }}
|
| 110 |
+
>
|
| 111 |
+
<div
|
| 112 |
+
className={`h-full rounded-xl shadow-2xl overflow-hidden border ${windowClass} ${className}`}
|
| 113 |
+
>
|
| 114 |
+
<div
|
| 115 |
+
className={`window-drag-handle h-12 flex items-center px-4 space-x-4 border-b cursor-move ${headerClass} ${headerClassName}`}
|
| 116 |
+
>
|
| 117 |
+
<div className="flex space-x-2">
|
| 118 |
+
<div className="traffic-light traffic-close" onClick={onClose} />
|
| 119 |
+
<div className="traffic-light traffic-min" onClick={onMinimize} />
|
| 120 |
+
<div className="traffic-light traffic-max" onClick={handleMaximize} />
|
| 121 |
+
</div>
|
| 122 |
+
<span className="font-semibold text-gray-700 flex-1 text-center pr-16">{title}</span>
|
| 123 |
+
</div>
|
| 124 |
+
<div className="flex-1 overflow-auto bg-white" style={{ height: 'calc(100% - 3rem)' }}>
|
| 125 |
+
{children}
|
| 126 |
+
</div>
|
| 127 |
+
</div>
|
| 128 |
+
</Rnd>
|
| 129 |
+
);
|
| 130 |
+
};
|
| 131 |
+
|
| 132 |
+
export default Window;
|
app/gemini/page.tsx
ADDED
|
@@ -0,0 +1,426 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import React, { useState, useRef, useEffect } from 'react'
|
| 4 |
+
import { Upload, Mic, MicOff, Send, Image as ImageIcon, FileText, Loader2, X } from 'lucide-react'
|
| 5 |
+
|
| 6 |
+
interface Message {
|
| 7 |
+
id: string
|
| 8 |
+
role: 'user' | 'assistant'
|
| 9 |
+
content: string
|
| 10 |
+
type: 'text' | 'image' | 'audio'
|
| 11 |
+
imageUrl?: string
|
| 12 |
+
timestamp: Date
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
export default function GeminiAIApp() {
|
| 16 |
+
const [messages, setMessages] = useState<Message[]>([])
|
| 17 |
+
const [inputText, setInputText] = useState('')
|
| 18 |
+
const [isLoading, setIsLoading] = useState(false)
|
| 19 |
+
const [isRecording, setIsRecording] = useState(false)
|
| 20 |
+
const [selectedImage, setSelectedImage] = useState<string | null>(null)
|
| 21 |
+
const [activeTab, setActiveTab] = useState<'chat' | 'transcribe' | 'image'>('chat')
|
| 22 |
+
const [apiKey, setApiKey] = useState('')
|
| 23 |
+
const [showApiKeyInput, setShowApiKeyInput] = useState(true)
|
| 24 |
+
|
| 25 |
+
const fileInputRef = useRef<HTMLInputElement>(null)
|
| 26 |
+
const audioInputRef = useRef<HTMLInputElement>(null)
|
| 27 |
+
const messagesEndRef = useRef<HTMLDivElement>(null)
|
| 28 |
+
const mediaRecorderRef = useRef<MediaRecorder | null>(null)
|
| 29 |
+
const audioChunksRef = useRef<Blob[]>([])
|
| 30 |
+
|
| 31 |
+
useEffect(() => {
|
| 32 |
+
// Check for stored API key
|
| 33 |
+
const storedKey = localStorage.getItem('gemini_api_key')
|
| 34 |
+
if (storedKey) {
|
| 35 |
+
setApiKey(storedKey)
|
| 36 |
+
setShowApiKeyInput(false)
|
| 37 |
+
}
|
| 38 |
+
}, [])
|
| 39 |
+
|
| 40 |
+
useEffect(() => {
|
| 41 |
+
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
| 42 |
+
}, [messages])
|
| 43 |
+
|
| 44 |
+
const saveApiKey = () => {
|
| 45 |
+
if (apiKey.trim()) {
|
| 46 |
+
localStorage.setItem('gemini_api_key', apiKey)
|
| 47 |
+
setShowApiKeyInput(false)
|
| 48 |
+
}
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
const handleSendMessage = async () => {
|
| 52 |
+
if (!inputText.trim() && !selectedImage) return
|
| 53 |
+
|
| 54 |
+
const newMessage: Message = {
|
| 55 |
+
id: Date.now().toString(),
|
| 56 |
+
role: 'user',
|
| 57 |
+
content: inputText || 'Image uploaded',
|
| 58 |
+
type: selectedImage ? 'image' : 'text',
|
| 59 |
+
imageUrl: selectedImage || undefined,
|
| 60 |
+
timestamp: new Date()
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
setMessages(prev => [...prev, newMessage])
|
| 64 |
+
setInputText('')
|
| 65 |
+
setSelectedImage(null)
|
| 66 |
+
setIsLoading(true)
|
| 67 |
+
|
| 68 |
+
try {
|
| 69 |
+
const response = await fetch('/api/gemini/chat', {
|
| 70 |
+
method: 'POST',
|
| 71 |
+
headers: { 'Content-Type': 'application/json' },
|
| 72 |
+
body: JSON.stringify({
|
| 73 |
+
message: inputText,
|
| 74 |
+
imageUrl: selectedImage,
|
| 75 |
+
apiKey: apiKey,
|
| 76 |
+
history: messages.slice(-10) // Send last 10 messages for context
|
| 77 |
+
})
|
| 78 |
+
})
|
| 79 |
+
|
| 80 |
+
const data = await response.json()
|
| 81 |
+
|
| 82 |
+
if (data.error) {
|
| 83 |
+
throw new Error(data.error)
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
const aiMessage: Message = {
|
| 87 |
+
id: (Date.now() + 1).toString(),
|
| 88 |
+
role: 'assistant',
|
| 89 |
+
content: data.response,
|
| 90 |
+
type: 'text',
|
| 91 |
+
timestamp: new Date()
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
setMessages(prev => [...prev, aiMessage])
|
| 95 |
+
} catch (error) {
|
| 96 |
+
console.error('Error:', error)
|
| 97 |
+
const errorMessage: Message = {
|
| 98 |
+
id: (Date.now() + 1).toString(),
|
| 99 |
+
role: 'assistant',
|
| 100 |
+
content: `Error: ${error instanceof Error ? error.message : 'Failed to get response'}`,
|
| 101 |
+
type: 'text',
|
| 102 |
+
timestamp: new Date()
|
| 103 |
+
}
|
| 104 |
+
setMessages(prev => [...prev, errorMessage])
|
| 105 |
+
} finally {
|
| 106 |
+
setIsLoading(false)
|
| 107 |
+
}
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
| 111 |
+
const file = e.target.files?.[0]
|
| 112 |
+
if (file) {
|
| 113 |
+
const reader = new FileReader()
|
| 114 |
+
reader.onloadend = () => {
|
| 115 |
+
setSelectedImage(reader.result as string)
|
| 116 |
+
}
|
| 117 |
+
reader.readAsDataURL(file)
|
| 118 |
+
}
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
const startRecording = async () => {
|
| 122 |
+
try {
|
| 123 |
+
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
| 124 |
+
const mediaRecorder = new MediaRecorder(stream)
|
| 125 |
+
mediaRecorderRef.current = mediaRecorder
|
| 126 |
+
audioChunksRef.current = []
|
| 127 |
+
|
| 128 |
+
mediaRecorder.ondataavailable = (event) => {
|
| 129 |
+
audioChunksRef.current.push(event.data)
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
mediaRecorder.onstop = async () => {
|
| 133 |
+
const audioBlob = new Blob(audioChunksRef.current, { type: 'audio/wav' })
|
| 134 |
+
await transcribeAudio(audioBlob)
|
| 135 |
+
stream.getTracks().forEach(track => track.stop())
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
mediaRecorder.start()
|
| 139 |
+
setIsRecording(true)
|
| 140 |
+
} catch (error) {
|
| 141 |
+
console.error('Error accessing microphone:', error)
|
| 142 |
+
alert('Error accessing microphone. Please check permissions.')
|
| 143 |
+
}
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
const stopRecording = () => {
|
| 147 |
+
if (mediaRecorderRef.current && isRecording) {
|
| 148 |
+
mediaRecorderRef.current.stop()
|
| 149 |
+
setIsRecording(false)
|
| 150 |
+
}
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
const transcribeAudio = async (audioBlob: Blob) => {
|
| 154 |
+
setIsLoading(true)
|
| 155 |
+
const formData = new FormData()
|
| 156 |
+
formData.append('audio', audioBlob, 'recording.wav')
|
| 157 |
+
formData.append('apiKey', apiKey)
|
| 158 |
+
|
| 159 |
+
try {
|
| 160 |
+
const response = await fetch('/api/gemini/transcribe', {
|
| 161 |
+
method: 'POST',
|
| 162 |
+
body: formData
|
| 163 |
+
})
|
| 164 |
+
|
| 165 |
+
const data = await response.json()
|
| 166 |
+
|
| 167 |
+
if (data.error) {
|
| 168 |
+
throw new Error(data.error)
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
setInputText(data.transcription)
|
| 172 |
+
} catch (error) {
|
| 173 |
+
console.error('Transcription error:', error)
|
| 174 |
+
alert('Failed to transcribe audio')
|
| 175 |
+
} finally {
|
| 176 |
+
setIsLoading(false)
|
| 177 |
+
}
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
const handleAudioFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
| 181 |
+
const file = e.target.files?.[0]
|
| 182 |
+
if (file) {
|
| 183 |
+
await transcribeAudio(file)
|
| 184 |
+
}
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
return (
|
| 188 |
+
<div className="min-h-screen bg-gradient-to-br from-purple-50 to-blue-50 p-4">
|
| 189 |
+
<div className="max-w-6xl mx-auto">
|
| 190 |
+
<div className="bg-white rounded-2xl shadow-xl overflow-hidden">
|
| 191 |
+
{/* Header */}
|
| 192 |
+
<div className="bg-gradient-to-r from-blue-600 to-purple-600 p-6 text-white">
|
| 193 |
+
<h1 className="text-3xl font-bold mb-2">Gemini AI Assistant</h1>
|
| 194 |
+
<p className="text-blue-100">Chat, Transcribe, and Analyze Images with AI</p>
|
| 195 |
+
</div>
|
| 196 |
+
|
| 197 |
+
{/* API Key Input */}
|
| 198 |
+
{showApiKeyInput && (
|
| 199 |
+
<div className="p-4 bg-yellow-50 border-b border-yellow-200">
|
| 200 |
+
<div className="flex gap-2">
|
| 201 |
+
<input
|
| 202 |
+
type="password"
|
| 203 |
+
placeholder="Enter your Gemini API Key"
|
| 204 |
+
value={apiKey}
|
| 205 |
+
onChange={(e) => setApiKey(e.target.value)}
|
| 206 |
+
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
| 207 |
+
/>
|
| 208 |
+
<button
|
| 209 |
+
onClick={saveApiKey}
|
| 210 |
+
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
| 211 |
+
>
|
| 212 |
+
Save Key
|
| 213 |
+
</button>
|
| 214 |
+
</div>
|
| 215 |
+
<p className="text-sm text-gray-600 mt-2">
|
| 216 |
+
Get your API key from <a href="https://makersuite.google.com/app/apikey" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline">Google AI Studio</a>
|
| 217 |
+
</p>
|
| 218 |
+
</div>
|
| 219 |
+
)}
|
| 220 |
+
|
| 221 |
+
{/* Tabs */}
|
| 222 |
+
<div className="flex border-b border-gray-200">
|
| 223 |
+
<button
|
| 224 |
+
onClick={() => setActiveTab('chat')}
|
| 225 |
+
className={`flex-1 py-3 px-4 font-medium transition-colors ${
|
| 226 |
+
activeTab === 'chat'
|
| 227 |
+
? 'bg-blue-50 text-blue-600 border-b-2 border-blue-600'
|
| 228 |
+
: 'text-gray-600 hover:bg-gray-50'
|
| 229 |
+
}`}
|
| 230 |
+
>
|
| 231 |
+
Chat
|
| 232 |
+
</button>
|
| 233 |
+
<button
|
| 234 |
+
onClick={() => setActiveTab('transcribe')}
|
| 235 |
+
className={`flex-1 py-3 px-4 font-medium transition-colors ${
|
| 236 |
+
activeTab === 'transcribe'
|
| 237 |
+
? 'bg-blue-50 text-blue-600 border-b-2 border-blue-600'
|
| 238 |
+
: 'text-gray-600 hover:bg-gray-50'
|
| 239 |
+
}`}
|
| 240 |
+
>
|
| 241 |
+
Transcribe
|
| 242 |
+
</button>
|
| 243 |
+
<button
|
| 244 |
+
onClick={() => setActiveTab('image')}
|
| 245 |
+
className={`flex-1 py-3 px-4 font-medium transition-colors ${
|
| 246 |
+
activeTab === 'image'
|
| 247 |
+
? 'bg-blue-50 text-blue-600 border-b-2 border-blue-600'
|
| 248 |
+
: 'text-gray-600 hover:bg-gray-50'
|
| 249 |
+
}`}
|
| 250 |
+
>
|
| 251 |
+
Image Analysis
|
| 252 |
+
</button>
|
| 253 |
+
</div>
|
| 254 |
+
|
| 255 |
+
{/* Chat Messages */}
|
| 256 |
+
<div className="h-[500px] overflow-y-auto p-4 space-y-4">
|
| 257 |
+
{messages.length === 0 && (
|
| 258 |
+
<div className="text-center text-gray-500 mt-20">
|
| 259 |
+
<div className="mb-4">
|
| 260 |
+
{activeTab === 'chat' && <FileText size={48} className="mx-auto text-gray-300" />}
|
| 261 |
+
{activeTab === 'transcribe' && <Mic size={48} className="mx-auto text-gray-300" />}
|
| 262 |
+
{activeTab === 'image' && <ImageIcon size={48} className="mx-auto text-gray-300" />}
|
| 263 |
+
</div>
|
| 264 |
+
<p className="text-lg font-medium">
|
| 265 |
+
{activeTab === 'chat' && 'Start a conversation with Gemini AI'}
|
| 266 |
+
{activeTab === 'transcribe' && 'Record or upload audio to transcribe'}
|
| 267 |
+
{activeTab === 'image' && 'Upload an image for AI analysis'}
|
| 268 |
+
</p>
|
| 269 |
+
</div>
|
| 270 |
+
)}
|
| 271 |
+
|
| 272 |
+
{messages.map((message) => (
|
| 273 |
+
<div
|
| 274 |
+
key={message.id}
|
| 275 |
+
className={`flex ${message.role === 'user' ? 'justify-end' : 'justify-start'}`}
|
| 276 |
+
>
|
| 277 |
+
<div
|
| 278 |
+
className={`max-w-[70%] p-4 rounded-2xl ${
|
| 279 |
+
message.role === 'user'
|
| 280 |
+
? 'bg-blue-600 text-white'
|
| 281 |
+
: 'bg-gray-100 text-gray-800'
|
| 282 |
+
}`}
|
| 283 |
+
>
|
| 284 |
+
{message.imageUrl && (
|
| 285 |
+
<img
|
| 286 |
+
src={message.imageUrl}
|
| 287 |
+
alt="Uploaded"
|
| 288 |
+
className="mb-2 rounded-lg max-h-64 object-contain"
|
| 289 |
+
/>
|
| 290 |
+
)}
|
| 291 |
+
<p className="whitespace-pre-wrap">{message.content}</p>
|
| 292 |
+
<p className={`text-xs mt-2 ${
|
| 293 |
+
message.role === 'user' ? 'text-blue-100' : 'text-gray-500'
|
| 294 |
+
}`}>
|
| 295 |
+
{new Date(message.timestamp).toLocaleTimeString()}
|
| 296 |
+
</p>
|
| 297 |
+
</div>
|
| 298 |
+
</div>
|
| 299 |
+
))}
|
| 300 |
+
|
| 301 |
+
{isLoading && (
|
| 302 |
+
<div className="flex justify-start">
|
| 303 |
+
<div className="bg-gray-100 p-4 rounded-2xl">
|
| 304 |
+
<Loader2 className="animate-spin h-5 w-5 text-gray-600" />
|
| 305 |
+
</div>
|
| 306 |
+
</div>
|
| 307 |
+
)}
|
| 308 |
+
|
| 309 |
+
<div ref={messagesEndRef} />
|
| 310 |
+
</div>
|
| 311 |
+
|
| 312 |
+
{/* Selected Image Preview */}
|
| 313 |
+
{selectedImage && (
|
| 314 |
+
<div className="px-4 pb-2">
|
| 315 |
+
<div className="relative inline-block">
|
| 316 |
+
<img
|
| 317 |
+
src={selectedImage}
|
| 318 |
+
alt="Selected"
|
| 319 |
+
className="h-20 rounded-lg border-2 border-blue-500"
|
| 320 |
+
/>
|
| 321 |
+
<button
|
| 322 |
+
onClick={() => setSelectedImage(null)}
|
| 323 |
+
className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full p-1 hover:bg-red-600"
|
| 324 |
+
>
|
| 325 |
+
<X size={16} />
|
| 326 |
+
</button>
|
| 327 |
+
</div>
|
| 328 |
+
</div>
|
| 329 |
+
)}
|
| 330 |
+
|
| 331 |
+
{/* Input Area */}
|
| 332 |
+
<div className="border-t border-gray-200 p-4">
|
| 333 |
+
<div className="flex gap-2">
|
| 334 |
+
{/* File Inputs */}
|
| 335 |
+
<input
|
| 336 |
+
ref={fileInputRef}
|
| 337 |
+
type="file"
|
| 338 |
+
accept="image/*"
|
| 339 |
+
onChange={handleImageUpload}
|
| 340 |
+
className="hidden"
|
| 341 |
+
/>
|
| 342 |
+
<input
|
| 343 |
+
ref={audioInputRef}
|
| 344 |
+
type="file"
|
| 345 |
+
accept="audio/*"
|
| 346 |
+
onChange={handleAudioFileUpload}
|
| 347 |
+
className="hidden"
|
| 348 |
+
/>
|
| 349 |
+
|
| 350 |
+
{/* Action Buttons */}
|
| 351 |
+
{activeTab === 'image' && (
|
| 352 |
+
<button
|
| 353 |
+
onClick={() => fileInputRef.current?.click()}
|
| 354 |
+
className="p-3 bg-purple-100 text-purple-600 rounded-lg hover:bg-purple-200 transition-colors"
|
| 355 |
+
title="Upload Image"
|
| 356 |
+
>
|
| 357 |
+
<ImageIcon size={20} />
|
| 358 |
+
</button>
|
| 359 |
+
)}
|
| 360 |
+
|
| 361 |
+
{activeTab === 'transcribe' && (
|
| 362 |
+
<>
|
| 363 |
+
<button
|
| 364 |
+
onClick={isRecording ? stopRecording : startRecording}
|
| 365 |
+
className={`p-3 rounded-lg transition-colors ${
|
| 366 |
+
isRecording
|
| 367 |
+
? 'bg-red-100 text-red-600 hover:bg-red-200 animate-pulse'
|
| 368 |
+
: 'bg-green-100 text-green-600 hover:bg-green-200'
|
| 369 |
+
}`}
|
| 370 |
+
title={isRecording ? 'Stop Recording' : 'Start Recording'}
|
| 371 |
+
>
|
| 372 |
+
{isRecording ? <MicOff size={20} /> : <Mic size={20} />}
|
| 373 |
+
</button>
|
| 374 |
+
<button
|
| 375 |
+
onClick={() => audioInputRef.current?.click()}
|
| 376 |
+
className="p-3 bg-blue-100 text-blue-600 rounded-lg hover:bg-blue-200 transition-colors"
|
| 377 |
+
title="Upload Audio File"
|
| 378 |
+
>
|
| 379 |
+
<Upload size={20} />
|
| 380 |
+
</button>
|
| 381 |
+
</>
|
| 382 |
+
)}
|
| 383 |
+
|
| 384 |
+
{/* Text Input */}
|
| 385 |
+
<input
|
| 386 |
+
type="text"
|
| 387 |
+
value={inputText}
|
| 388 |
+
onChange={(e) => setInputText(e.target.value)}
|
| 389 |
+
onKeyPress={(e) => e.key === 'Enter' && !e.shiftKey && handleSendMessage()}
|
| 390 |
+
placeholder={
|
| 391 |
+
activeTab === 'chat' ? 'Type your message...' :
|
| 392 |
+
activeTab === 'transcribe' ? 'Transcribed text will appear here...' :
|
| 393 |
+
'Describe what you want to analyze...'
|
| 394 |
+
}
|
| 395 |
+
className="flex-1 px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
| 396 |
+
disabled={isLoading}
|
| 397 |
+
/>
|
| 398 |
+
|
| 399 |
+
{/* Send Button */}
|
| 400 |
+
<button
|
| 401 |
+
onClick={handleSendMessage}
|
| 402 |
+
disabled={isLoading || (!inputText.trim() && !selectedImage)}
|
| 403 |
+
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:bg-gray-300 disabled:cursor-not-allowed"
|
| 404 |
+
>
|
| 405 |
+
{isLoading ? <Loader2 className="animate-spin h-5 w-5" /> : <Send size={20} />}
|
| 406 |
+
</button>
|
| 407 |
+
</div>
|
| 408 |
+
|
| 409 |
+
{/* Feature Instructions */}
|
| 410 |
+
<div className="mt-4 text-sm text-gray-600">
|
| 411 |
+
{activeTab === 'chat' && (
|
| 412 |
+
<p>Chat with Gemini AI. Ask questions, get creative responses, or have a conversation.</p>
|
| 413 |
+
)}
|
| 414 |
+
{activeTab === 'transcribe' && (
|
| 415 |
+
<p>Record audio or upload an audio file to transcribe it to text using AI.</p>
|
| 416 |
+
)}
|
| 417 |
+
{activeTab === 'image' && (
|
| 418 |
+
<p>Upload an image to get AI-powered analysis, descriptions, or answers about the image content.</p>
|
| 419 |
+
)}
|
| 420 |
+
</div>
|
| 421 |
+
</div>
|
| 422 |
+
</div>
|
| 423 |
+
</div>
|
| 424 |
+
</div>
|
| 425 |
+
)
|
| 426 |
+
}
|
app/globals.css
CHANGED
|
@@ -1,26 +1,315 @@
|
|
| 1 |
@import "tailwindcss";
|
|
|
|
|
|
|
| 2 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
:root {
|
| 4 |
-
--
|
| 5 |
-
--
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
}
|
| 7 |
|
| 8 |
@theme inline {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
--color-background: var(--background);
|
| 10 |
--color-foreground: var(--foreground);
|
| 11 |
-
--
|
| 12 |
-
--
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
}
|
| 14 |
|
| 15 |
-
@
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
--foreground: #ededed;
|
| 19 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
}
|
| 21 |
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
font-family: Arial, Helvetica, sans-serif;
|
| 26 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
@import "tailwindcss";
|
| 2 |
+
@import "tw-animate-css";
|
| 3 |
+
@import "./styles/theme.css";
|
| 4 |
|
| 5 |
+
/* Tailwind base layer for compatibility */
|
| 6 |
+
@layer base {
|
| 7 |
+
*,
|
| 8 |
+
::after,
|
| 9 |
+
::before,
|
| 10 |
+
::backdrop,
|
| 11 |
+
::file-selector-button {
|
| 12 |
+
border-color: var(--color-gray-200, currentColor);
|
| 13 |
+
}
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
/* Custom variant for dark mode */
|
| 17 |
+
@custom-variant dark (&:is(.dark *));
|
| 18 |
+
|
| 19 |
+
/* CSS Variables for theming */
|
| 20 |
:root {
|
| 21 |
+
--radius: 0.625rem;
|
| 22 |
+
--background: oklch(1 0 0);
|
| 23 |
+
--foreground: oklch(0.145 0 0);
|
| 24 |
+
--card: oklch(1 0 0);
|
| 25 |
+
--card-foreground: oklch(0.145 0 0);
|
| 26 |
+
--popover: oklch(1 0 0);
|
| 27 |
+
--popover-foreground: oklch(0.145 0 0);
|
| 28 |
+
--primary: oklch(0.205 0 0);
|
| 29 |
+
--primary-foreground: oklch(0.985 0 0);
|
| 30 |
+
--secondary: oklch(0.97 0 0);
|
| 31 |
+
--secondary-foreground: oklch(0.205 0 0);
|
| 32 |
+
--muted: oklch(0.97 0 0);
|
| 33 |
+
--muted-foreground: oklch(0.556 0 0);
|
| 34 |
+
--accent: oklch(0.97 0 0);
|
| 35 |
+
--accent-foreground: oklch(0.205 0 0);
|
| 36 |
+
--destructive: oklch(0.577 0.245 27.325);
|
| 37 |
+
--destructive-foreground: oklch(1 0 0);
|
| 38 |
+
--border: oklch(0.922 0 0);
|
| 39 |
+
--input: oklch(0.922 0 0);
|
| 40 |
+
--ring: oklch(0.708 0 0);
|
| 41 |
+
--chart-1: oklch(0.646 0.222 41.116);
|
| 42 |
+
--chart-2: oklch(0.6 0.118 184.704);
|
| 43 |
+
--chart-3: oklch(0.398 0.07 227.392);
|
| 44 |
+
--chart-4: oklch(0.828 0.189 84.429);
|
| 45 |
+
--chart-5: oklch(0.769 0.188 70.08);
|
| 46 |
+
--sidebar: oklch(0.985 0 0);
|
| 47 |
+
--sidebar-foreground: oklch(0.145 0 0);
|
| 48 |
+
--sidebar-primary: oklch(0.205 0 0);
|
| 49 |
+
--sidebar-primary-foreground: oklch(0.985 0 0);
|
| 50 |
+
--sidebar-accent: oklch(0.97 0 0);
|
| 51 |
+
--sidebar-accent-foreground: oklch(0.205 0 0);
|
| 52 |
+
--sidebar-border: oklch(0.922 0 0);
|
| 53 |
+
--sidebar-ring: oklch(0.708 0 0);
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
.dark {
|
| 57 |
+
--background: oklch(0.145 0 0);
|
| 58 |
+
--foreground: oklch(0.985 0 0);
|
| 59 |
+
--card: oklch(0.205 0 0);
|
| 60 |
+
--card-foreground: oklch(0.985 0 0);
|
| 61 |
+
--popover: oklch(0.205 0 0);
|
| 62 |
+
--popover-foreground: oklch(0.985 0 0);
|
| 63 |
+
--primary: oklch(0.922 0 0);
|
| 64 |
+
--primary-foreground: oklch(0.205 0 0);
|
| 65 |
+
--secondary: oklch(0.269 0 0);
|
| 66 |
+
--secondary-foreground: oklch(0.985 0 0);
|
| 67 |
+
--muted: oklch(0.269 0 0);
|
| 68 |
+
--muted-foreground: oklch(0.708 0 0);
|
| 69 |
+
--accent: oklch(0.269 0 0);
|
| 70 |
+
--accent-foreground: oklch(0.985 0 0);
|
| 71 |
+
--destructive: oklch(0.704 0.191 22.216);
|
| 72 |
+
--destructive-foreground: oklch(1 0 0);
|
| 73 |
+
--border: oklch(1 0 0 / 10%);
|
| 74 |
+
--input: oklch(1 0 0 / 15%);
|
| 75 |
+
--ring: oklch(0.556 0 0);
|
| 76 |
+
--chart-1: oklch(0.488 0.243 264.376);
|
| 77 |
+
--chart-2: oklch(0.696 0.17 162.48);
|
| 78 |
+
--chart-3: oklch(0.769 0.188 70.08);
|
| 79 |
+
--chart-4: oklch(0.627 0.265 303.9);
|
| 80 |
+
--chart-5: oklch(0.645 0.246 16.439);
|
| 81 |
+
--sidebar: oklch(0.205 0 0);
|
| 82 |
+
--sidebar-foreground: oklch(0.985 0 0);
|
| 83 |
+
--sidebar-primary: oklch(0.488 0.243 264.376);
|
| 84 |
+
--sidebar-primary-foreground: oklch(0.985 0 0);
|
| 85 |
+
--sidebar-accent: oklch(0.269 0 0);
|
| 86 |
+
--sidebar-accent-foreground: oklch(0.985 0 0);
|
| 87 |
+
--sidebar-border: oklch(1 0 0 / 10%);
|
| 88 |
+
--sidebar-ring: oklch(0.556 0 0);
|
| 89 |
}
|
| 90 |
|
| 91 |
@theme inline {
|
| 92 |
+
--radius-sm: calc(var(--radius) - 4px);
|
| 93 |
+
--radius-md: calc(var(--radius) - 2px);
|
| 94 |
+
--radius-lg: var(--radius);
|
| 95 |
+
--radius-xl: calc(var(--radius) + 4px);
|
| 96 |
--color-background: var(--background);
|
| 97 |
--color-foreground: var(--foreground);
|
| 98 |
+
--color-card: var(--card);
|
| 99 |
+
--color-card-foreground: var(--card-foreground);
|
| 100 |
+
--color-popover: var(--popover);
|
| 101 |
+
--color-popover-foreground: var(--popover-foreground);
|
| 102 |
+
--color-primary: var(--primary);
|
| 103 |
+
--color-primary-foreground: var(--primary-foreground);
|
| 104 |
+
--color-secondary: var(--secondary);
|
| 105 |
+
--color-secondary-foreground: var(--secondary-foreground);
|
| 106 |
+
--color-muted: var(--muted);
|
| 107 |
+
--color-muted-foreground: var(--muted-foreground);
|
| 108 |
+
--color-accent: var(--accent);
|
| 109 |
+
--color-accent-foreground: var(--accent-foreground);
|
| 110 |
+
--color-destructive: var(--destructive);
|
| 111 |
+
--color-destructive-foreground: var(--destructive-foreground);
|
| 112 |
+
--color-border: var(--border);
|
| 113 |
+
--color-input: var(--input);
|
| 114 |
+
--color-ring: var(--ring);
|
| 115 |
+
--color-chart-1: var(--chart-1);
|
| 116 |
+
--color-chart-2: var(--chart-2);
|
| 117 |
+
--color-chart-3: var(--chart-3);
|
| 118 |
+
--color-chart-4: var(--chart-4);
|
| 119 |
+
--color-chart-5: var(--chart-5);
|
| 120 |
+
--color-sidebar: var(--sidebar);
|
| 121 |
+
--color-sidebar-foreground: var(--sidebar-foreground);
|
| 122 |
+
--color-sidebar-primary: var(--sidebar-primary);
|
| 123 |
+
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
| 124 |
+
--color-sidebar-accent: var(--sidebar-accent);
|
| 125 |
+
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
| 126 |
+
--color-sidebar-border: var(--sidebar-border);
|
| 127 |
+
--color-sidebar-ring: var(--sidebar-ring);
|
| 128 |
+
--animate-accordion-down: accordion-down 0.2s ease-out;
|
| 129 |
+
--animate-accordion-up: accordion-up 0.2s ease-out;
|
| 130 |
+
|
| 131 |
+
@keyframes accordion-down {
|
| 132 |
+
from {
|
| 133 |
+
height: 0;
|
| 134 |
+
}
|
| 135 |
+
to {
|
| 136 |
+
height: var(--radix-accordion-content-height);
|
| 137 |
+
}
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
@keyframes accordion-up {
|
| 141 |
+
from {
|
| 142 |
+
height: var(--radix-accordion-content-height);
|
| 143 |
+
}
|
| 144 |
+
to {
|
| 145 |
+
height: 0;
|
| 146 |
+
}
|
| 147 |
+
}
|
| 148 |
}
|
| 149 |
|
| 150 |
+
@layer base {
|
| 151 |
+
* {
|
| 152 |
+
@apply border-border outline-ring/50;
|
|
|
|
| 153 |
}
|
| 154 |
+
|
| 155 |
+
body {
|
| 156 |
+
@apply bg-background text-foreground;
|
| 157 |
+
font-family: var(--font-inter, 'Inter'), 'SF Pro Display', -apple-system, BlinkMacSystemFont, 'Ubuntu', ui-sans-serif, system-ui, sans-serif;
|
| 158 |
+
overflow: hidden;
|
| 159 |
+
touch-action: none;
|
| 160 |
+
user-select: none;
|
| 161 |
+
-webkit-font-smoothing: antialiased;
|
| 162 |
+
-moz-osx-font-smoothing: grayscale;
|
| 163 |
+
}
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
/* Sonoma Background */
|
| 167 |
+
.bg-sonoma {
|
| 168 |
+
background: linear-gradient(135deg, #e0c3fc 0%, #8ec5fc 100%);
|
| 169 |
+
background-size: cover;
|
| 170 |
+
background-position: center;
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
/* Glassmorphism utilities */
|
| 174 |
+
.glass {
|
| 175 |
+
background: rgba(255, 255, 255, 0.65);
|
| 176 |
+
backdrop-filter: blur(20px);
|
| 177 |
+
-webkit-backdrop-filter: blur(20px);
|
| 178 |
+
border: 1px solid rgba(255, 255, 255, 0.3);
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
.glass-dark {
|
| 182 |
+
background: rgba(0, 0, 0, 0.5);
|
| 183 |
+
backdrop-filter: blur(20px);
|
| 184 |
+
-webkit-backdrop-filter: blur(20px);
|
| 185 |
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
.dock-glass {
|
| 189 |
+
background: rgba(255, 255, 255, 0.4);
|
| 190 |
+
backdrop-filter: blur(15px);
|
| 191 |
+
-webkit-backdrop-filter: blur(15px);
|
| 192 |
+
border: 1px solid rgba(255, 255, 255, 0.2);
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
/* App Icon Styles */
|
| 196 |
+
.app-icon {
|
| 197 |
+
transition: transform 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
| 198 |
+
}
|
| 199 |
+
.app-icon:hover {
|
| 200 |
+
transform: scale(1.2) translateY(-10px);
|
| 201 |
+
}
|
| 202 |
+
.app-icon:active {
|
| 203 |
+
transform: scale(0.95);
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
/* Window Animations */
|
| 207 |
+
.window-transition {
|
| 208 |
+
transition: opacity 0.2s, transform 0.2s;
|
| 209 |
+
}
|
| 210 |
+
.window-hidden {
|
| 211 |
+
opacity: 0;
|
| 212 |
+
transform: scale(0.95);
|
| 213 |
+
pointer-events: none;
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
/* Traffic Lights */
|
| 217 |
+
.traffic-light {
|
| 218 |
+
width: 12px;
|
| 219 |
+
height: 12px;
|
| 220 |
+
border-radius: 50%;
|
| 221 |
+
margin-right: 8px;
|
| 222 |
+
position: relative;
|
| 223 |
+
cursor: pointer;
|
| 224 |
+
transition: all 0.2s ease;
|
| 225 |
+
}
|
| 226 |
+
.traffic-close {
|
| 227 |
+
background-color: #ff5f56;
|
| 228 |
+
border: 1px solid #e0443e;
|
| 229 |
+
}
|
| 230 |
+
.traffic-min {
|
| 231 |
+
background-color: #ffbd2e;
|
| 232 |
+
border: 1px solid #dea123;
|
| 233 |
+
}
|
| 234 |
+
.traffic-max {
|
| 235 |
+
background-color: #27c93f;
|
| 236 |
+
border: 1px solid #1aab29;
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
.traffic-light:hover::after {
|
| 240 |
+
content: '';
|
| 241 |
+
position: absolute;
|
| 242 |
+
top: 0;
|
| 243 |
+
left: 0;
|
| 244 |
+
right: 0;
|
| 245 |
+
bottom: 0;
|
| 246 |
+
display: flex;
|
| 247 |
+
align-items: center;
|
| 248 |
+
justify-content: center;
|
| 249 |
+
font-size: 8px;
|
| 250 |
+
color: rgba(0,0,0,0.5);
|
| 251 |
+
font-weight: bold;
|
| 252 |
+
}
|
| 253 |
+
.traffic-close:hover::after { content: '×'; }
|
| 254 |
+
.traffic-min:hover::after { content: '−'; }
|
| 255 |
+
.traffic-max:hover::after { content: '+'; }
|
| 256 |
+
|
| 257 |
+
/* Custom Scrollbar */
|
| 258 |
+
::-webkit-scrollbar {
|
| 259 |
+
width: 8px;
|
| 260 |
+
height: 8px;
|
| 261 |
+
}
|
| 262 |
+
::-webkit-scrollbar-track {
|
| 263 |
+
background: transparent;
|
| 264 |
+
}
|
| 265 |
+
::-webkit-scrollbar-thumb {
|
| 266 |
+
background: rgba(0,0,0,0.2);
|
| 267 |
+
border-radius: 4px;
|
| 268 |
+
}
|
| 269 |
+
::-webkit-scrollbar-thumb:hover {
|
| 270 |
+
background: rgba(0,0,0,0.3);
|
| 271 |
}
|
| 272 |
|
| 273 |
+
/* Desktop Icon Grid */
|
| 274 |
+
.desktop-icon {
|
| 275 |
+
@apply flex flex-col items-center gap-1 cursor-pointer transition-all;
|
|
|
|
| 276 |
}
|
| 277 |
+
.desktop-icon:hover {
|
| 278 |
+
transform: scale(1.05);
|
| 279 |
+
}
|
| 280 |
+
.desktop-icon-label {
|
| 281 |
+
@apply text-xs text-white font-medium px-2 py-0.5 rounded;
|
| 282 |
+
background: rgba(0,0,0,0.3);
|
| 283 |
+
text-shadow: 0 1px 2px rgba(0,0,0,0.5);
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
/* macOS style window */
|
| 287 |
+
.macos-window {
|
| 288 |
+
@apply rounded-xl shadow-2xl overflow-hidden;
|
| 289 |
+
background: #f5f5f5;
|
| 290 |
+
border: 1px solid rgba(0, 0, 0, 0.1);
|
| 291 |
+
}
|
| 292 |
+
|
| 293 |
+
.macos-window-header {
|
| 294 |
+
@apply h-10 flex items-center px-4 justify-between;
|
| 295 |
+
background: #f1f1f1;
|
| 296 |
+
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
|
| 297 |
+
}
|
| 298 |
+
|
| 299 |
+
/* Dock styles */
|
| 300 |
+
.dock-container {
|
| 301 |
+
@apply fixed bottom-2 left-0 right-0 flex justify-center z-50;
|
| 302 |
+
}
|
| 303 |
+
|
| 304 |
+
.dock-item {
|
| 305 |
+
@apply relative flex flex-col items-center gap-1;
|
| 306 |
+
}
|
| 307 |
+
|
| 308 |
+
.dock-dot {
|
| 309 |
+
@apply w-1 h-1 rounded-full mt-1 opacity-0 transition-opacity;
|
| 310 |
+
background: rgba(0, 0, 0, 0.5);
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
.dock-item:hover .dock-dot {
|
| 314 |
+
@apply opacity-100;
|
| 315 |
+
}
|
app/hooks/useKV.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import { useState, useEffect } from 'react'
|
| 4 |
+
|
| 5 |
+
// Custom implementation of useKV hook for key-value storage
|
| 6 |
+
export function useKV<T>(key: string, defaultValue: T): [T, (value: T) => void] {
|
| 7 |
+
// Initialize state with the default value
|
| 8 |
+
const [value, setValue] = useState<T>(() => {
|
| 9 |
+
// Try to get the value from localStorage on initial load
|
| 10 |
+
if (typeof window !== 'undefined') {
|
| 11 |
+
try {
|
| 12 |
+
const storedValue = localStorage.getItem(key)
|
| 13 |
+
if (storedValue !== null) {
|
| 14 |
+
return JSON.parse(storedValue)
|
| 15 |
+
}
|
| 16 |
+
} catch (error) {
|
| 17 |
+
console.error(`Error reading from localStorage for key "${key}":`, error)
|
| 18 |
+
}
|
| 19 |
+
}
|
| 20 |
+
return defaultValue
|
| 21 |
+
})
|
| 22 |
+
|
| 23 |
+
// Update localStorage whenever the value changes
|
| 24 |
+
useEffect(() => {
|
| 25 |
+
if (typeof window !== 'undefined') {
|
| 26 |
+
try {
|
| 27 |
+
localStorage.setItem(key, JSON.stringify(value))
|
| 28 |
+
} catch (error) {
|
| 29 |
+
console.error(`Error writing to localStorage for key "${key}":`, error)
|
| 30 |
+
}
|
| 31 |
+
}
|
| 32 |
+
}, [key, value])
|
| 33 |
+
|
| 34 |
+
return [value, setValue]
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
export default useKV
|