Spaces:
Sleeping
Sleeping
Seth
commited on
Commit
·
f80e9b3
1
Parent(s):
cf6980b
update
Browse files- README.md +264 -5
- backend/app/__init__.py +2 -0
- backend/app/main.py +210 -6
- backend/app/models.py +86 -0
- backend/app/schemas.py +115 -0
- backend/app/services/__init__.py +2 -0
- backend/app/services/ai_service.py +91 -0
- backend/app/services/canva_service.py +82 -0
- backend/app/services/linkedin_service.py +113 -0
- backend/requirements.txt +12 -1
- frontend/index.html +1 -1
- frontend/package.json +28 -3
- frontend/postcss.config.js +7 -0
- frontend/src/App.jsx +21 -19
- frontend/src/components/Layout.jsx +219 -0
- frontend/src/components/ui/alert.jsx +50 -0
- frontend/src/components/ui/avatar.jsx +39 -0
- frontend/src/components/ui/badge.jsx +32 -0
- frontend/src/components/ui/button.jsx +50 -0
- frontend/src/components/ui/calendar.jsx +54 -0
- frontend/src/components/ui/card.jsx +61 -0
- frontend/src/components/ui/checkbox.jsx +25 -0
- frontend/src/components/ui/dialog.jsx +99 -0
- frontend/src/components/ui/dropdown-menu.jsx +160 -0
- frontend/src/components/ui/input.jsx +20 -0
- frontend/src/components/ui/label.jsx +18 -0
- frontend/src/components/ui/popover.jsx +24 -0
- frontend/src/components/ui/progress.jsx +23 -0
- frontend/src/components/ui/select.jsx +135 -0
- frontend/src/components/ui/separator.jsx +26 -0
- frontend/src/components/ui/slider.jsx +23 -0
- frontend/src/components/ui/switch.jsx +24 -0
- frontend/src/components/ui/tabs.jsx +43 -0
- frontend/src/components/ui/textarea.jsx +19 -0
- frontend/src/index.css +38 -0
- frontend/src/main.jsx +1 -0
- frontend/src/pages/Dashboard.jsx +252 -0
- frontend/src/pages/Integrations.jsx +420 -0
- frontend/src/pages/PostEditor.jsx +513 -0
- frontend/src/pages/Repository.jsx +510 -0
- frontend/src/pages/Scheduler.jsx +493 -0
- frontend/src/utils.js +18 -0
- frontend/tailwind.config.js +12 -0
- frontend/vite.config.js +6 -0
README.md
CHANGED
|
@@ -1,10 +1,269 @@
|
|
| 1 |
---
|
| 2 |
-
title: PostGen
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: PostGen - LinkedIn Content Scheduler
|
| 3 |
+
emoji: 🚀
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: indigo
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
| 8 |
---
|
| 9 |
|
| 10 |
+
# PostGen - AI-Powered LinkedIn Content Scheduler
|
| 11 |
+
|
| 12 |
+
PostGen is a comprehensive LinkedIn content scheduling application that integrates with Canva and LinkedIn APIs to automate content creation and posting. The app uses GPT for AI-generated content and Canva brand templates for consistent visual design.
|
| 13 |
+
|
| 14 |
+
## Features
|
| 15 |
+
|
| 16 |
+
- **AI Content Generation**: Uses GPT to generate engaging LinkedIn posts
|
| 17 |
+
- **Canva Integration**: Access and apply Canva brand templates using the Autofill API
|
| 18 |
+
- **LinkedIn Scheduling**: Schedule and publish posts directly to LinkedIn
|
| 19 |
+
- **Asset Repository**: Upload and organize marketing materials by product categories
|
| 20 |
+
- **Smart Scheduler**: Agentic AI automatically generates content schedules based on date ranges, products, and post types
|
| 21 |
+
- **Product Categories**: Support for OCR, P2P, and O2C products with sub-categories
|
| 22 |
+
|
| 23 |
+
## Tech Stack
|
| 24 |
+
|
| 25 |
+
- **Frontend**: React, React Router, Tailwind CSS, Framer Motion, shadcn/ui
|
| 26 |
+
- **Backend**: FastAPI, Python
|
| 27 |
+
- **AI**: OpenAI GPT (latest model)
|
| 28 |
+
- **APIs**: Canva Connect API, LinkedIn API
|
| 29 |
+
|
| 30 |
+
## Setup Instructions
|
| 31 |
+
|
| 32 |
+
### Prerequisites
|
| 33 |
+
|
| 34 |
+
1. **Canva Account**:
|
| 35 |
+
- Canva Teams account (free trial available)
|
| 36 |
+
- Canva Connect API integration created
|
| 37 |
+
- Autofill API access registered
|
| 38 |
+
|
| 39 |
+
2. **LinkedIn Account**:
|
| 40 |
+
- LinkedIn Developer account
|
| 41 |
+
- LinkedIn App created with appropriate permissions
|
| 42 |
+
|
| 43 |
+
3. **OpenAI Account**:
|
| 44 |
+
- OpenAI API key with access to GPT models
|
| 45 |
+
|
| 46 |
+
### Environment Variables
|
| 47 |
+
|
| 48 |
+
Create a `.env` file in the backend directory with the following variables:
|
| 49 |
+
|
| 50 |
+
```env
|
| 51 |
+
# OpenAI
|
| 52 |
+
OPENAI_API_KEY=your_openai_api_key
|
| 53 |
+
OPENAI_MODEL=gpt-4o
|
| 54 |
+
|
| 55 |
+
# Canva (optional - can be passed via API)
|
| 56 |
+
CANVA_ACCESS_TOKEN=your_canva_access_token
|
| 57 |
+
|
| 58 |
+
# LinkedIn (optional - can be passed via API)
|
| 59 |
+
LINKEDIN_ACCESS_TOKEN=your_linkedin_access_token
|
| 60 |
+
LINKEDIN_PERSON_URN=your_linkedin_person_urn
|
| 61 |
+
|
| 62 |
+
# Database (for production)
|
| 63 |
+
DATABASE_URL=postgresql://user:password@localhost/postgen
|
| 64 |
+
```
|
| 65 |
+
|
| 66 |
+
### Local Development
|
| 67 |
+
|
| 68 |
+
1. **Install Frontend Dependencies**:
|
| 69 |
+
```bash
|
| 70 |
+
cd frontend
|
| 71 |
+
npm install
|
| 72 |
+
```
|
| 73 |
+
|
| 74 |
+
2. **Install Backend Dependencies**:
|
| 75 |
+
```bash
|
| 76 |
+
cd backend
|
| 77 |
+
pip install -r requirements.txt
|
| 78 |
+
```
|
| 79 |
+
|
| 80 |
+
3. **Run Frontend** (development mode):
|
| 81 |
+
```bash
|
| 82 |
+
cd frontend
|
| 83 |
+
npm run dev
|
| 84 |
+
```
|
| 85 |
+
|
| 86 |
+
4. **Run Backend**:
|
| 87 |
+
```bash
|
| 88 |
+
cd backend
|
| 89 |
+
uvicorn app.main:app --reload --port 8000
|
| 90 |
+
```
|
| 91 |
+
|
| 92 |
+
### Building for Production
|
| 93 |
+
|
| 94 |
+
1. **Build Frontend**:
|
| 95 |
+
```bash
|
| 96 |
+
cd frontend
|
| 97 |
+
npm run build
|
| 98 |
+
```
|
| 99 |
+
|
| 100 |
+
2. **Build Docker Image**:
|
| 101 |
+
```bash
|
| 102 |
+
docker build -t postgen .
|
| 103 |
+
```
|
| 104 |
+
|
| 105 |
+
3. **Run Docker Container**:
|
| 106 |
+
```bash
|
| 107 |
+
docker run -p 7860:7860 --env-file .env postgen
|
| 108 |
+
```
|
| 109 |
+
|
| 110 |
+
## Deployment to HuggingFace Spaces
|
| 111 |
+
|
| 112 |
+
### Step 1: Prepare Your Repository
|
| 113 |
+
|
| 114 |
+
1. Ensure all code is committed and pushed to your Git repository
|
| 115 |
+
2. Make sure the `Dockerfile` is in the root directory
|
| 116 |
+
3. Ensure `README.md` has the HuggingFace Spaces configuration at the top
|
| 117 |
+
|
| 118 |
+
### Step 2: Create a HuggingFace Space
|
| 119 |
+
|
| 120 |
+
1. Go to [HuggingFace Spaces](https://huggingface.co/spaces)
|
| 121 |
+
2. Click "Create new Space"
|
| 122 |
+
3. Choose:
|
| 123 |
+
- **SDK**: Docker
|
| 124 |
+
- **Name**: postgen (or your preferred name)
|
| 125 |
+
- **Visibility**: Public or Private
|
| 126 |
+
|
| 127 |
+
### Step 3: Configure Environment Variables
|
| 128 |
+
|
| 129 |
+
1. In your HuggingFace Space settings, go to "Variables and secrets"
|
| 130 |
+
2. Add the following secrets:
|
| 131 |
+
- `OPENAI_API_KEY`: Your OpenAI API key
|
| 132 |
+
- `OPENAI_MODEL`: gpt-4o (or your preferred model)
|
| 133 |
+
- `CANVA_ACCESS_TOKEN`: (Optional, can be set per user)
|
| 134 |
+
- `LINKEDIN_ACCESS_TOKEN`: (Optional, can be set per user)
|
| 135 |
+
|
| 136 |
+
### Step 4: Connect Your Repository
|
| 137 |
+
|
| 138 |
+
1. In Space settings, connect your Git repository
|
| 139 |
+
2. Or push your code directly to the HuggingFace Space repository
|
| 140 |
+
|
| 141 |
+
### Step 5: Deploy
|
| 142 |
+
|
| 143 |
+
1. HuggingFace will automatically build and deploy your Docker image
|
| 144 |
+
2. Monitor the build logs in the Space interface
|
| 145 |
+
3. Once deployed, your app will be available at: `https://huggingface.co/spaces/your-username/postgen`
|
| 146 |
+
|
| 147 |
+
## API Integration Guide
|
| 148 |
+
|
| 149 |
+
### Canva Integration
|
| 150 |
+
|
| 151 |
+
1. **Get Access Token**:
|
| 152 |
+
- Create a Canva Connect API integration
|
| 153 |
+
- Complete OAuth flow to get access token
|
| 154 |
+
- Token should have scopes: `design:content:write`, `design:content:read`, `brandtemplate:content:read`, `brandtemplate:meta:read`
|
| 155 |
+
|
| 156 |
+
2. **Using Brand Templates**:
|
| 157 |
+
- Call `/api/canva/brand-templates` to get available templates
|
| 158 |
+
- Call `/api/canva/brand-templates/{id}/dataset` to get template structure
|
| 159 |
+
- Call `/api/canva/autofill` to create a design from template
|
| 160 |
+
- Poll `/api/canva/autofill/{job_id}` to check status
|
| 161 |
+
|
| 162 |
+
### LinkedIn Integration
|
| 163 |
+
|
| 164 |
+
1. **Get Access Token**:
|
| 165 |
+
- Create a LinkedIn App
|
| 166 |
+
- Request permissions: `w_member_social`, `r_liteprofile`
|
| 167 |
+
- Complete OAuth flow to get access token
|
| 168 |
+
|
| 169 |
+
2. **Posting to LinkedIn**:
|
| 170 |
+
- Call `/api/linkedin/post` with your access token and post content
|
| 171 |
+
- Media can be included via `media_uris` parameter
|
| 172 |
+
|
| 173 |
+
### AI Content Generation
|
| 174 |
+
|
| 175 |
+
1. **Generate Content**:
|
| 176 |
+
- Call `/api/ai/generate-content` with:
|
| 177 |
+
- `product_category`: 'ocr', 'p2p', or 'o2c'
|
| 178 |
+
- `post_type`: 'carousel', 'cover_content', 'content_only', or 'webinar'
|
| 179 |
+
- `context`: Optional additional context
|
| 180 |
+
- `assets`: Optional list of asset IDs
|
| 181 |
+
|
| 182 |
+
## Project Structure
|
| 183 |
+
|
| 184 |
+
```
|
| 185 |
+
PostGen/
|
| 186 |
+
├── frontend/
|
| 187 |
+
│ ├── src/
|
| 188 |
+
│ │ ├── components/
|
| 189 |
+
│ │ │ ├── ui/ # shadcn/ui components
|
| 190 |
+
│ │ │ └── Layout.jsx # Main layout component
|
| 191 |
+
│ │ ├── pages/
|
| 192 |
+
│ │ │ ├── Dashboard.jsx
|
| 193 |
+
│ │ │ ├── Repository.jsx
|
| 194 |
+
│ │ │ ├── Scheduler.jsx
|
| 195 |
+
│ │ │ ├── PostEditor.jsx
|
| 196 |
+
│ │ │ └── Integrations.jsx
|
| 197 |
+
│ │ ├── App.jsx
|
| 198 |
+
│ │ ├── main.jsx
|
| 199 |
+
│ │ └── utils.js
|
| 200 |
+
│ ├── package.json
|
| 201 |
+
│ └── vite.config.js
|
| 202 |
+
├── backend/
|
| 203 |
+
│ ├── app/
|
| 204 |
+
│ │ ├── services/
|
| 205 |
+
│ │ │ ├── canva_service.py
|
| 206 |
+
│ │ │ ├── linkedin_service.py
|
| 207 |
+
│ │ │ └── ai_service.py
|
| 208 |
+
│ │ ├── models.py
|
| 209 |
+
│ │ ├── schemas.py
|
| 210 |
+
│ │ └── main.py
|
| 211 |
+
│ └── requirements.txt
|
| 212 |
+
├── Dockerfile
|
| 213 |
+
└── README.md
|
| 214 |
+
```
|
| 215 |
+
|
| 216 |
+
## Next Steps After Deployment
|
| 217 |
+
|
| 218 |
+
1. **Connect Integrations**:
|
| 219 |
+
- Go to the Integrations page
|
| 220 |
+
- Connect your Canva account
|
| 221 |
+
- Connect your LinkedIn account
|
| 222 |
+
|
| 223 |
+
2. **Upload Assets**:
|
| 224 |
+
- Go to Repository page
|
| 225 |
+
- Upload marketing materials, screenshots, and documents
|
| 226 |
+
- Classify them by product category
|
| 227 |
+
|
| 228 |
+
3. **Create Campaign**:
|
| 229 |
+
- Go to Scheduler page
|
| 230 |
+
- Click "Campaign Settings"
|
| 231 |
+
- Configure date range, products, post types, and frequency
|
| 232 |
+
- Click "Generate Schedule"
|
| 233 |
+
|
| 234 |
+
4. **Review and Schedule**:
|
| 235 |
+
- Review AI-generated posts
|
| 236 |
+
- Edit content if needed
|
| 237 |
+
- Confirm and schedule posts
|
| 238 |
+
|
| 239 |
+
## Troubleshooting
|
| 240 |
+
|
| 241 |
+
### Build Issues
|
| 242 |
+
|
| 243 |
+
- **Frontend build fails**: Check Node.js version (requires 18+)
|
| 244 |
+
- **Backend import errors**: Ensure all Python packages are installed
|
| 245 |
+
- **Docker build fails**: Check Dockerfile syntax and paths
|
| 246 |
+
|
| 247 |
+
### Runtime Issues
|
| 248 |
+
|
| 249 |
+
- **API errors**: Verify environment variables are set correctly
|
| 250 |
+
- **Canva API errors**: Check access token and scopes
|
| 251 |
+
- **LinkedIn API errors**: Verify OAuth permissions
|
| 252 |
+
- **AI generation fails**: Check OpenAI API key and quota
|
| 253 |
+
|
| 254 |
+
### Common Issues
|
| 255 |
+
|
| 256 |
+
1. **CORS errors**: Already handled in backend CORS middleware
|
| 257 |
+
2. **Port conflicts**: HuggingFace Spaces uses port 7860
|
| 258 |
+
3. **File uploads**: Ensure uploads directory has write permissions
|
| 259 |
+
|
| 260 |
+
## Support
|
| 261 |
+
|
| 262 |
+
For issues or questions:
|
| 263 |
+
- Check the API documentation in the code
|
| 264 |
+
- Review Canva API docs: https://www.canva.dev/docs/connect/
|
| 265 |
+
- Review LinkedIn API docs: https://docs.microsoft.com/en-us/linkedin/
|
| 266 |
+
|
| 267 |
+
## License
|
| 268 |
+
|
| 269 |
+
This project is private and proprietary.
|
backend/app/__init__.py
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# PostGen Backend Application
|
| 2 |
+
|
backend/app/main.py
CHANGED
|
@@ -1,10 +1,22 @@
|
|
| 1 |
-
from fastapi import FastAPI
|
| 2 |
-
from fastapi.responses import FileResponse
|
| 3 |
from fastapi.staticfiles import StaticFiles
|
| 4 |
from fastapi.middleware.cors import CORSMiddleware
|
| 5 |
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
| 6 |
|
| 7 |
-
app
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
app.add_middleware(
|
| 10 |
CORSMiddleware,
|
|
@@ -14,14 +26,206 @@ app.add_middleware(
|
|
| 14 |
allow_headers=["*"],
|
| 15 |
)
|
| 16 |
|
| 17 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
@app.get("/api/health")
|
| 19 |
def health():
|
| 20 |
-
return {"status": "ok"}
|
| 21 |
|
| 22 |
@app.get("/api/hello")
|
| 23 |
def hello():
|
| 24 |
-
return {"message": "Hello from
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
|
| 26 |
# ---- Frontend static serving ----
|
| 27 |
FRONTEND_DIST = Path(__file__).resolve().parents[2] / "frontend" / "dist"
|
|
|
|
| 1 |
+
from fastapi import FastAPI, UploadFile, File, HTTPException, Depends
|
| 2 |
+
from fastapi.responses import FileResponse, JSONResponse
|
| 3 |
from fastapi.staticfiles import StaticFiles
|
| 4 |
from fastapi.middleware.cors import CORSMiddleware
|
| 5 |
from pathlib import Path
|
| 6 |
+
from typing import List, Optional
|
| 7 |
+
import os
|
| 8 |
+
from datetime import datetime
|
| 9 |
|
| 10 |
+
from app.schemas import (
|
| 11 |
+
IntegrationResponse, AssetResponse, PostResponse, CampaignResponse,
|
| 12 |
+
CanvaBrandTemplate, CanvaAutofillRequest, CanvaAutofillResponse,
|
| 13 |
+
LinkedInPostRequest, AIContentRequest, AIContentResponse
|
| 14 |
+
)
|
| 15 |
+
from app.services.canva_service import CanvaService
|
| 16 |
+
from app.services.linkedin_service import LinkedInService
|
| 17 |
+
from app.services.ai_service import AIService
|
| 18 |
+
|
| 19 |
+
app = FastAPI(title="PostGen API", version="1.0.0")
|
| 20 |
|
| 21 |
app.add_middleware(
|
| 22 |
CORSMiddleware,
|
|
|
|
| 26 |
allow_headers=["*"],
|
| 27 |
)
|
| 28 |
|
| 29 |
+
# Services
|
| 30 |
+
ai_service = AIService()
|
| 31 |
+
|
| 32 |
+
# ---- API Endpoints ----
|
| 33 |
+
|
| 34 |
@app.get("/api/health")
|
| 35 |
def health():
|
| 36 |
+
return {"status": "ok", "message": "PostGen API is running"}
|
| 37 |
|
| 38 |
@app.get("/api/hello")
|
| 39 |
def hello():
|
| 40 |
+
return {"message": "Hello from PostGen API"}
|
| 41 |
+
|
| 42 |
+
# ---- Canva Integration ----
|
| 43 |
+
|
| 44 |
+
@app.get("/api/canva/brand-templates", response_model=List[CanvaBrandTemplate])
|
| 45 |
+
async def get_canva_brand_templates(access_token: str):
|
| 46 |
+
"""Get list of Canva brand templates"""
|
| 47 |
+
try:
|
| 48 |
+
canva_service = CanvaService(access_token)
|
| 49 |
+
templates = await canva_service.get_brand_templates()
|
| 50 |
+
return templates
|
| 51 |
+
except Exception as e:
|
| 52 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 53 |
+
|
| 54 |
+
@app.get("/api/canva/brand-templates/{template_id}/dataset")
|
| 55 |
+
async def get_canva_template_dataset(template_id: str, access_token: str):
|
| 56 |
+
"""Get dataset for a specific brand template"""
|
| 57 |
+
try:
|
| 58 |
+
canva_service = CanvaService(access_token)
|
| 59 |
+
dataset = await canva_service.get_brand_template_dataset(template_id)
|
| 60 |
+
return dataset
|
| 61 |
+
except Exception as e:
|
| 62 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 63 |
+
|
| 64 |
+
@app.post("/api/canva/autofill", response_model=CanvaAutofillResponse)
|
| 65 |
+
async def create_canva_autofill(request: CanvaAutofillRequest, access_token: str):
|
| 66 |
+
"""Create an autofill job for a brand template"""
|
| 67 |
+
try:
|
| 68 |
+
canva_service = CanvaService(access_token)
|
| 69 |
+
response = await canva_service.create_autofill_job(request)
|
| 70 |
+
return response
|
| 71 |
+
except Exception as e:
|
| 72 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 73 |
+
|
| 74 |
+
@app.get("/api/canva/autofill/{job_id}")
|
| 75 |
+
async def get_canva_autofill_status(job_id: str, access_token: str):
|
| 76 |
+
"""Get status of an autofill job"""
|
| 77 |
+
try:
|
| 78 |
+
canva_service = CanvaService(access_token)
|
| 79 |
+
status = await canva_service.get_autofill_job_status(job_id)
|
| 80 |
+
return status
|
| 81 |
+
except Exception as e:
|
| 82 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 83 |
+
|
| 84 |
+
# ---- LinkedIn Integration ----
|
| 85 |
+
|
| 86 |
+
@app.post("/api/linkedin/post")
|
| 87 |
+
async def create_linkedin_post(request: LinkedInPostRequest, access_token: str):
|
| 88 |
+
"""Create a LinkedIn post"""
|
| 89 |
+
try:
|
| 90 |
+
linkedin_service = LinkedInService(access_token)
|
| 91 |
+
result = await linkedin_service.create_post(
|
| 92 |
+
text=request.text,
|
| 93 |
+
media_uris=request.media_uris
|
| 94 |
+
)
|
| 95 |
+
return result
|
| 96 |
+
except Exception as e:
|
| 97 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 98 |
+
|
| 99 |
+
@app.get("/api/linkedin/profile")
|
| 100 |
+
async def get_linkedin_profile(access_token: str):
|
| 101 |
+
"""Get LinkedIn user profile"""
|
| 102 |
+
try:
|
| 103 |
+
linkedin_service = LinkedInService(access_token)
|
| 104 |
+
profile = await linkedin_service.get_user_profile()
|
| 105 |
+
return profile
|
| 106 |
+
except Exception as e:
|
| 107 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 108 |
+
|
| 109 |
+
# ---- AI Content Generation ----
|
| 110 |
+
|
| 111 |
+
@app.post("/api/ai/generate-content", response_model=AIContentResponse)
|
| 112 |
+
async def generate_ai_content(request: AIContentRequest):
|
| 113 |
+
"""Generate LinkedIn post content using GPT"""
|
| 114 |
+
try:
|
| 115 |
+
# Get assets context if provided
|
| 116 |
+
assets_context = None
|
| 117 |
+
if request.assets:
|
| 118 |
+
# In a real implementation, fetch asset descriptions from database
|
| 119 |
+
assets_context = f"User has {len(request.assets)} assets available"
|
| 120 |
+
|
| 121 |
+
response = await ai_service.generate_content(request, assets_context)
|
| 122 |
+
return response
|
| 123 |
+
except Exception as e:
|
| 124 |
+
raise HTTPException(status_code=500, detail=f"AI generation failed: {str(e)}")
|
| 125 |
+
|
| 126 |
+
# ---- Asset Management ----
|
| 127 |
+
|
| 128 |
+
@app.post("/api/assets/upload")
|
| 129 |
+
async def upload_asset(
|
| 130 |
+
file: UploadFile = File(...),
|
| 131 |
+
product_category: str = None,
|
| 132 |
+
sub_category: Optional[str] = None
|
| 133 |
+
):
|
| 134 |
+
"""Upload an asset to the repository"""
|
| 135 |
+
try:
|
| 136 |
+
# Create uploads directory if it doesn't exist
|
| 137 |
+
upload_dir = Path("uploads")
|
| 138 |
+
upload_dir.mkdir(exist_ok=True)
|
| 139 |
+
|
| 140 |
+
# Save file
|
| 141 |
+
file_path = upload_dir / file.filename
|
| 142 |
+
with open(file_path, "wb") as buffer:
|
| 143 |
+
content = await file.read()
|
| 144 |
+
buffer.write(content)
|
| 145 |
+
|
| 146 |
+
# In a real implementation, save to database
|
| 147 |
+
return {
|
| 148 |
+
"id": 1,
|
| 149 |
+
"name": file.filename,
|
| 150 |
+
"file_type": file.content_type.split('/')[0] if file.content_type else "unknown",
|
| 151 |
+
"product_category": product_category,
|
| 152 |
+
"sub_category": sub_category,
|
| 153 |
+
"size": len(content),
|
| 154 |
+
"created_at": datetime.utcnow().isoformat()
|
| 155 |
+
}
|
| 156 |
+
except Exception as e:
|
| 157 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 158 |
+
|
| 159 |
+
@app.get("/api/assets", response_model=List[AssetResponse])
|
| 160 |
+
async def get_assets(product_category: Optional[str] = None):
|
| 161 |
+
"""Get list of assets"""
|
| 162 |
+
# Mock data for now
|
| 163 |
+
return [
|
| 164 |
+
{
|
| 165 |
+
"id": 1,
|
| 166 |
+
"name": "OCR_Demo_Screenshot.png",
|
| 167 |
+
"file_type": "image",
|
| 168 |
+
"product_category": "ocr",
|
| 169 |
+
"sub_category": None,
|
| 170 |
+
"size": 2516582,
|
| 171 |
+
"created_at": datetime.utcnow()
|
| 172 |
+
}
|
| 173 |
+
]
|
| 174 |
+
|
| 175 |
+
# ---- Post Management ----
|
| 176 |
+
|
| 177 |
+
@app.post("/api/posts", response_model=PostResponse)
|
| 178 |
+
async def create_post(post_data: dict):
|
| 179 |
+
"""Create a new post"""
|
| 180 |
+
# In a real implementation, save to database
|
| 181 |
+
return {
|
| 182 |
+
"id": 1,
|
| 183 |
+
"title": post_data.get("title", "New Post"),
|
| 184 |
+
"content": post_data.get("content", ""),
|
| 185 |
+
"post_type": post_data.get("post_type", "content_only"),
|
| 186 |
+
"product_category": post_data.get("product_category", "ocr"),
|
| 187 |
+
"scheduled_date": post_data.get("scheduled_date", datetime.utcnow()),
|
| 188 |
+
"status": "draft",
|
| 189 |
+
"created_at": datetime.utcnow()
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
@app.get("/api/posts", response_model=List[PostResponse])
|
| 193 |
+
async def get_posts():
|
| 194 |
+
"""Get list of posts"""
|
| 195 |
+
# Mock data for now
|
| 196 |
+
return [
|
| 197 |
+
{
|
| 198 |
+
"id": 1,
|
| 199 |
+
"title": "OCR Document Automation Benefits",
|
| 200 |
+
"content": "Transform your document processing...",
|
| 201 |
+
"post_type": "carousel",
|
| 202 |
+
"product_category": "ocr",
|
| 203 |
+
"scheduled_date": datetime.utcnow(),
|
| 204 |
+
"status": "scheduled",
|
| 205 |
+
"created_at": datetime.utcnow()
|
| 206 |
+
}
|
| 207 |
+
]
|
| 208 |
+
|
| 209 |
+
# ---- Campaign Management ----
|
| 210 |
+
|
| 211 |
+
@app.post("/api/campaigns/generate")
|
| 212 |
+
async def generate_campaign(campaign_data: dict):
|
| 213 |
+
"""Generate a campaign schedule using agentic AI"""
|
| 214 |
+
try:
|
| 215 |
+
# This would use AI to generate a schedule based on:
|
| 216 |
+
# - Date range
|
| 217 |
+
# - Products to focus on
|
| 218 |
+
# - Post types mix
|
| 219 |
+
# - Posts per week
|
| 220 |
+
|
| 221 |
+
# Mock implementation
|
| 222 |
+
return {
|
| 223 |
+
"campaign_id": 1,
|
| 224 |
+
"generated_posts": 12,
|
| 225 |
+
"schedule": []
|
| 226 |
+
}
|
| 227 |
+
except Exception as e:
|
| 228 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 229 |
|
| 230 |
# ---- Frontend static serving ----
|
| 231 |
FRONTEND_DIST = Path(__file__).resolve().parents[2] / "frontend" / "dist"
|
backend/app/models.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlalchemy import Column, Integer, String, DateTime, Boolean, JSON, Text, ForeignKey
|
| 2 |
+
from sqlalchemy.ext.declarative import declarative_base
|
| 3 |
+
from sqlalchemy.orm import relationship
|
| 4 |
+
from datetime import datetime
|
| 5 |
+
|
| 6 |
+
Base = declarative_base()
|
| 7 |
+
|
| 8 |
+
class User(Base):
|
| 9 |
+
__tablename__ = "users"
|
| 10 |
+
|
| 11 |
+
id = Column(Integer, primary_key=True, index=True)
|
| 12 |
+
email = Column(String, unique=True, index=True)
|
| 13 |
+
name = Column(String)
|
| 14 |
+
created_at = Column(DateTime, default=datetime.utcnow)
|
| 15 |
+
|
| 16 |
+
integrations = relationship("Integration", back_populates="user")
|
| 17 |
+
assets = relationship("Asset", back_populates="user")
|
| 18 |
+
posts = relationship("Post", back_populates="user")
|
| 19 |
+
|
| 20 |
+
class Integration(Base):
|
| 21 |
+
__tablename__ = "integrations"
|
| 22 |
+
|
| 23 |
+
id = Column(Integer, primary_key=True, index=True)
|
| 24 |
+
user_id = Column(Integer, ForeignKey("users.id"))
|
| 25 |
+
provider = Column(String) # 'linkedin' or 'canva'
|
| 26 |
+
access_token = Column(Text)
|
| 27 |
+
refresh_token = Column(Text)
|
| 28 |
+
expires_at = Column(DateTime)
|
| 29 |
+
account_info = Column(JSON)
|
| 30 |
+
connected = Column(Boolean, default=False)
|
| 31 |
+
created_at = Column(DateTime, default=datetime.utcnow)
|
| 32 |
+
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
| 33 |
+
|
| 34 |
+
user = relationship("User", back_populates="integrations")
|
| 35 |
+
|
| 36 |
+
class Asset(Base):
|
| 37 |
+
__tablename__ = "assets"
|
| 38 |
+
|
| 39 |
+
id = Column(Integer, primary_key=True, index=True)
|
| 40 |
+
user_id = Column(Integer, ForeignKey("users.id"))
|
| 41 |
+
name = Column(String)
|
| 42 |
+
file_path = Column(String)
|
| 43 |
+
file_type = Column(String) # 'image', 'document', 'video'
|
| 44 |
+
product_category = Column(String) # 'ocr', 'p2p', 'o2c'
|
| 45 |
+
sub_category = Column(String, nullable=True)
|
| 46 |
+
size = Column(Integer) # in bytes
|
| 47 |
+
metadata = Column(JSON, nullable=True)
|
| 48 |
+
created_at = Column(DateTime, default=datetime.utcnow)
|
| 49 |
+
|
| 50 |
+
user = relationship("User", back_populates="assets")
|
| 51 |
+
|
| 52 |
+
class Post(Base):
|
| 53 |
+
__tablename__ = "posts"
|
| 54 |
+
|
| 55 |
+
id = Column(Integer, primary_key=True, index=True)
|
| 56 |
+
user_id = Column(Integer, ForeignKey("users.id"))
|
| 57 |
+
title = Column(String)
|
| 58 |
+
content = Column(Text)
|
| 59 |
+
post_type = Column(String) # 'carousel', 'cover_content', 'content_only', 'webinar'
|
| 60 |
+
product_category = Column(String)
|
| 61 |
+
scheduled_date = Column(DateTime)
|
| 62 |
+
status = Column(String) # 'draft', 'scheduled', 'published', 'failed'
|
| 63 |
+
linkedin_post_id = Column(String, nullable=True)
|
| 64 |
+
canva_design_id = Column(String, nullable=True)
|
| 65 |
+
assets = Column(JSON) # List of asset IDs
|
| 66 |
+
metadata = Column(JSON, nullable=True)
|
| 67 |
+
created_at = Column(DateTime, default=datetime.utcnow)
|
| 68 |
+
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
| 69 |
+
|
| 70 |
+
user = relationship("User", back_populates="posts")
|
| 71 |
+
|
| 72 |
+
class Campaign(Base):
|
| 73 |
+
__tablename__ = "campaigns"
|
| 74 |
+
|
| 75 |
+
id = Column(Integer, primary_key=True, index=True)
|
| 76 |
+
user_id = Column(Integer, ForeignKey("users.id"))
|
| 77 |
+
name = Column(String)
|
| 78 |
+
date_range_start = Column(DateTime)
|
| 79 |
+
date_range_end = Column(DateTime)
|
| 80 |
+
products = Column(JSON) # List of product IDs
|
| 81 |
+
post_types = Column(JSON) # List of post type IDs
|
| 82 |
+
posts_per_week = Column(Integer)
|
| 83 |
+
status = Column(String) # 'active', 'paused', 'completed'
|
| 84 |
+
created_at = Column(DateTime, default=datetime.utcnow)
|
| 85 |
+
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
| 86 |
+
|
backend/app/schemas.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel, EmailStr
|
| 2 |
+
from datetime import datetime
|
| 3 |
+
from typing import Optional, List, Dict, Any
|
| 4 |
+
|
| 5 |
+
class IntegrationCreate(BaseModel):
|
| 6 |
+
provider: str
|
| 7 |
+
access_token: str
|
| 8 |
+
refresh_token: Optional[str] = None
|
| 9 |
+
expires_at: Optional[datetime] = None
|
| 10 |
+
account_info: Optional[Dict[str, Any]] = None
|
| 11 |
+
|
| 12 |
+
class IntegrationResponse(BaseModel):
|
| 13 |
+
id: int
|
| 14 |
+
provider: str
|
| 15 |
+
connected: bool
|
| 16 |
+
account_info: Optional[Dict[str, Any]] = None
|
| 17 |
+
created_at: datetime
|
| 18 |
+
|
| 19 |
+
class Config:
|
| 20 |
+
from_attributes = True
|
| 21 |
+
|
| 22 |
+
class AssetCreate(BaseModel):
|
| 23 |
+
name: str
|
| 24 |
+
file_type: str
|
| 25 |
+
product_category: str
|
| 26 |
+
sub_category: Optional[str] = None
|
| 27 |
+
size: int
|
| 28 |
+
metadata: Optional[Dict[str, Any]] = None
|
| 29 |
+
|
| 30 |
+
class AssetResponse(BaseModel):
|
| 31 |
+
id: int
|
| 32 |
+
name: str
|
| 33 |
+
file_type: str
|
| 34 |
+
product_category: str
|
| 35 |
+
sub_category: Optional[str] = None
|
| 36 |
+
size: int
|
| 37 |
+
created_at: datetime
|
| 38 |
+
|
| 39 |
+
class Config:
|
| 40 |
+
from_attributes = True
|
| 41 |
+
|
| 42 |
+
class PostCreate(BaseModel):
|
| 43 |
+
title: str
|
| 44 |
+
content: str
|
| 45 |
+
post_type: str
|
| 46 |
+
product_category: str
|
| 47 |
+
scheduled_date: datetime
|
| 48 |
+
assets: Optional[List[int]] = None
|
| 49 |
+
|
| 50 |
+
class PostResponse(BaseModel):
|
| 51 |
+
id: int
|
| 52 |
+
title: str
|
| 53 |
+
content: str
|
| 54 |
+
post_type: str
|
| 55 |
+
product_category: str
|
| 56 |
+
scheduled_date: datetime
|
| 57 |
+
status: str
|
| 58 |
+
created_at: datetime
|
| 59 |
+
|
| 60 |
+
class Config:
|
| 61 |
+
from_attributes = True
|
| 62 |
+
|
| 63 |
+
class CampaignCreate(BaseModel):
|
| 64 |
+
name: str
|
| 65 |
+
date_range_start: datetime
|
| 66 |
+
date_range_end: datetime
|
| 67 |
+
products: List[str]
|
| 68 |
+
post_types: List[str]
|
| 69 |
+
posts_per_week: int
|
| 70 |
+
|
| 71 |
+
class CampaignResponse(BaseModel):
|
| 72 |
+
id: int
|
| 73 |
+
name: str
|
| 74 |
+
date_range_start: datetime
|
| 75 |
+
date_range_end: datetime
|
| 76 |
+
products: List[str]
|
| 77 |
+
post_types: List[str]
|
| 78 |
+
posts_per_week: int
|
| 79 |
+
status: str
|
| 80 |
+
created_at: datetime
|
| 81 |
+
|
| 82 |
+
class Config:
|
| 83 |
+
from_attributes = True
|
| 84 |
+
|
| 85 |
+
class CanvaBrandTemplate(BaseModel):
|
| 86 |
+
id: str
|
| 87 |
+
title: str
|
| 88 |
+
view_url: str
|
| 89 |
+
create_url: str
|
| 90 |
+
thumbnail: Optional[Dict[str, Any]] = None
|
| 91 |
+
|
| 92 |
+
class CanvaAutofillRequest(BaseModel):
|
| 93 |
+
brand_template_id: str
|
| 94 |
+
title: str
|
| 95 |
+
data: Dict[str, Any]
|
| 96 |
+
|
| 97 |
+
class CanvaAutofillResponse(BaseModel):
|
| 98 |
+
job_id: str
|
| 99 |
+
status: str
|
| 100 |
+
|
| 101 |
+
class LinkedInPostRequest(BaseModel):
|
| 102 |
+
text: str
|
| 103 |
+
media_uris: Optional[List[str]] = None
|
| 104 |
+
scheduled_time: Optional[datetime] = None
|
| 105 |
+
|
| 106 |
+
class AIContentRequest(BaseModel):
|
| 107 |
+
product_category: str
|
| 108 |
+
post_type: str
|
| 109 |
+
context: Optional[str] = None
|
| 110 |
+
assets: Optional[List[int]] = None
|
| 111 |
+
|
| 112 |
+
class AIContentResponse(BaseModel):
|
| 113 |
+
content: str
|
| 114 |
+
suggested_hashtags: List[str]
|
| 115 |
+
|
backend/app/services/__init__.py
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Services package
|
| 2 |
+
|
backend/app/services/ai_service.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import httpx
|
| 2 |
+
import os
|
| 3 |
+
from typing import List, Dict, Any, Optional
|
| 4 |
+
from openai import OpenAI
|
| 5 |
+
from app.schemas import AIContentRequest, AIContentResponse
|
| 6 |
+
|
| 7 |
+
class AIService:
|
| 8 |
+
def __init__(self):
|
| 9 |
+
self.client = OpenAI(api_key=os.getenv("OPENAI_API_KEY", ""))
|
| 10 |
+
self.model = os.getenv("OPENAI_MODEL", "gpt-4o")
|
| 11 |
+
|
| 12 |
+
async def generate_content(self, request: AIContentRequest, assets_context: Optional[str] = None) -> AIContentResponse:
|
| 13 |
+
"""Generate LinkedIn post content using GPT"""
|
| 14 |
+
|
| 15 |
+
product_descriptions = {
|
| 16 |
+
"ocr": "Intelligent Document Parsing (OCR) - AI-powered document processing and data extraction",
|
| 17 |
+
"p2p": "Purchase To Pay (P2P) - End-to-end procurement and accounts payable automation",
|
| 18 |
+
"o2c": "Order to Cash (O2C) - Complete order management and accounts receivable workflow"
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
post_type_descriptions = {
|
| 22 |
+
"carousel": "A multi-slide carousel post with visual storytelling",
|
| 23 |
+
"cover_content": "A post with a cover image and engaging text content",
|
| 24 |
+
"content_only": "A text-only post focused on valuable insights",
|
| 25 |
+
"webinar": "A webinar invitation post to promote an upcoming event"
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
system_prompt = f"""You are an expert LinkedIn content creator specializing in B2B SaaS marketing.
|
| 29 |
+
Create engaging, professional LinkedIn posts that:
|
| 30 |
+
- Are authentic and valuable to the audience
|
| 31 |
+
- Include relevant hashtags (3-5 hashtags)
|
| 32 |
+
- Use emojis sparingly and appropriately
|
| 33 |
+
- Are optimized for engagement
|
| 34 |
+
- Follow LinkedIn best practices
|
| 35 |
+
|
| 36 |
+
Product: {product_descriptions.get(request.product_category, request.product_category)}
|
| 37 |
+
Post Type: {post_type_descriptions.get(request.post_type, request.post_type)}
|
| 38 |
+
"""
|
| 39 |
+
|
| 40 |
+
user_prompt = f"""Create a LinkedIn post about {product_descriptions.get(request.product_category, request.product_category)}.
|
| 41 |
+
Post type: {post_type_descriptions.get(request.post_type, request.post_type)}
|
| 42 |
+
|
| 43 |
+
{f'Additional context: {request.context}' if request.context else ''}
|
| 44 |
+
{f'Available assets: {assets_context}' if assets_context else ''}
|
| 45 |
+
|
| 46 |
+
Make it engaging, professional, and include relevant hashtags at the end."""
|
| 47 |
+
|
| 48 |
+
try:
|
| 49 |
+
response = self.client.chat.completions.create(
|
| 50 |
+
model=self.model,
|
| 51 |
+
messages=[
|
| 52 |
+
{"role": "system", "content": system_prompt},
|
| 53 |
+
{"role": "user", "content": user_prompt}
|
| 54 |
+
],
|
| 55 |
+
temperature=0.7,
|
| 56 |
+
max_tokens=1000
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
content = response.choices[0].message.content
|
| 60 |
+
|
| 61 |
+
# Extract hashtags
|
| 62 |
+
hashtags = []
|
| 63 |
+
lines = content.split('\n')
|
| 64 |
+
for line in lines:
|
| 65 |
+
if '#' in line:
|
| 66 |
+
hashtags.extend([tag.strip() for tag in line.split() if tag.startswith('#')])
|
| 67 |
+
|
| 68 |
+
# If no hashtags found, generate some
|
| 69 |
+
if not hashtags:
|
| 70 |
+
hashtags = self._generate_hashtags(request.product_category)
|
| 71 |
+
|
| 72 |
+
return AIContentResponse(
|
| 73 |
+
content=content,
|
| 74 |
+
suggested_hashtags=hashtags[:5]
|
| 75 |
+
)
|
| 76 |
+
except Exception as e:
|
| 77 |
+
# Fallback content if AI fails
|
| 78 |
+
return AIContentResponse(
|
| 79 |
+
content=f"🚀 Exciting news about {product_descriptions.get(request.product_category, 'our product')}!\n\nStay tuned for more updates.\n\n#Innovation #Technology",
|
| 80 |
+
suggested_hashtags=["#Innovation", "#Technology", "#Business"]
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
def _generate_hashtags(self, product_category: str) -> List[str]:
|
| 84 |
+
"""Generate relevant hashtags based on product category"""
|
| 85 |
+
hashtag_map = {
|
| 86 |
+
"ocr": ["#DocumentAutomation", "#OCR", "#AITechnology", "#DigitalTransformation", "#BusinessEfficiency"],
|
| 87 |
+
"p2p": ["#Procurement", "#AccountsPayable", "#FinanceAutomation", "#BusinessProcess", "#Efficiency"],
|
| 88 |
+
"o2c": ["#OrderManagement", "#AccountsReceivable", "#SalesAutomation", "#BusinessGrowth", "#CustomerExperience"]
|
| 89 |
+
}
|
| 90 |
+
return hashtag_map.get(product_category, ["#Innovation", "#Technology", "#Business"])
|
| 91 |
+
|
backend/app/services/canva_service.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import httpx
|
| 2 |
+
import os
|
| 3 |
+
from typing import List, Dict, Any, Optional
|
| 4 |
+
from app.schemas import CanvaBrandTemplate, CanvaAutofillRequest, CanvaAutofillResponse
|
| 5 |
+
|
| 6 |
+
CANVA_API_BASE = "https://api.canva.com/rest/v1"
|
| 7 |
+
|
| 8 |
+
class CanvaService:
|
| 9 |
+
def __init__(self, access_token: str):
|
| 10 |
+
self.access_token = access_token
|
| 11 |
+
self.headers = {
|
| 12 |
+
"Authorization": f"Bearer {access_token}",
|
| 13 |
+
"Content-Type": "application/json"
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
async def get_brand_templates(self, dataset: str = "non_empty") -> List[CanvaBrandTemplate]:
|
| 17 |
+
"""Get list of Canva brand templates"""
|
| 18 |
+
async with httpx.AsyncClient() as client:
|
| 19 |
+
response = await client.get(
|
| 20 |
+
f"{CANVA_API_BASE}/brand-templates",
|
| 21 |
+
params={"dataset": dataset},
|
| 22 |
+
headers=self.headers,
|
| 23 |
+
timeout=30.0
|
| 24 |
+
)
|
| 25 |
+
response.raise_for_status()
|
| 26 |
+
data = response.json()
|
| 27 |
+
|
| 28 |
+
templates = []
|
| 29 |
+
for item in data.get("items", []):
|
| 30 |
+
templates.append(CanvaBrandTemplate(
|
| 31 |
+
id=item["id"],
|
| 32 |
+
title=item["title"],
|
| 33 |
+
view_url=item.get("view_url", ""),
|
| 34 |
+
create_url=item.get("create_url", ""),
|
| 35 |
+
thumbnail=item.get("thumbnail")
|
| 36 |
+
))
|
| 37 |
+
|
| 38 |
+
return templates
|
| 39 |
+
|
| 40 |
+
async def get_brand_template_dataset(self, template_id: str) -> Dict[str, Any]:
|
| 41 |
+
"""Get dataset for a specific brand template"""
|
| 42 |
+
async with httpx.AsyncClient() as client:
|
| 43 |
+
response = await client.get(
|
| 44 |
+
f"{CANVA_API_BASE}/brand-templates/{template_id}/dataset",
|
| 45 |
+
headers=self.headers,
|
| 46 |
+
timeout=30.0
|
| 47 |
+
)
|
| 48 |
+
response.raise_for_status()
|
| 49 |
+
return response.json()
|
| 50 |
+
|
| 51 |
+
async def create_autofill_job(self, request: CanvaAutofillRequest) -> CanvaAutofillResponse:
|
| 52 |
+
"""Create an autofill job for a brand template"""
|
| 53 |
+
async with httpx.AsyncClient() as client:
|
| 54 |
+
response = await client.post(
|
| 55 |
+
f"{CANVA_API_BASE}/autofills",
|
| 56 |
+
headers=self.headers,
|
| 57 |
+
json={
|
| 58 |
+
"brand_template_id": request.brand_template_id,
|
| 59 |
+
"title": request.title,
|
| 60 |
+
"data": request.data
|
| 61 |
+
},
|
| 62 |
+
timeout=30.0
|
| 63 |
+
)
|
| 64 |
+
response.raise_for_status()
|
| 65 |
+
data = response.json()
|
| 66 |
+
|
| 67 |
+
return CanvaAutofillResponse(
|
| 68 |
+
job_id=data["job"]["id"],
|
| 69 |
+
status=data["job"]["status"]
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
async def get_autofill_job_status(self, job_id: str) -> Dict[str, Any]:
|
| 73 |
+
"""Get status of an autofill job"""
|
| 74 |
+
async with httpx.AsyncClient() as client:
|
| 75 |
+
response = await client.get(
|
| 76 |
+
f"{CANVA_API_BASE}/autofills/{job_id}",
|
| 77 |
+
headers=self.headers,
|
| 78 |
+
timeout=30.0
|
| 79 |
+
)
|
| 80 |
+
response.raise_for_status()
|
| 81 |
+
return response.json()
|
| 82 |
+
|
backend/app/services/linkedin_service.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import httpx
|
| 2 |
+
import os
|
| 3 |
+
from typing import Dict, Any, Optional
|
| 4 |
+
from datetime import datetime
|
| 5 |
+
|
| 6 |
+
LINKEDIN_API_BASE = "https://api.linkedin.com/v2"
|
| 7 |
+
|
| 8 |
+
class LinkedInService:
|
| 9 |
+
def __init__(self, access_token: str):
|
| 10 |
+
self.access_token = access_token
|
| 11 |
+
self.headers = {
|
| 12 |
+
"Authorization": f"Bearer {access_token}",
|
| 13 |
+
"Content-Type": "application/json",
|
| 14 |
+
"X-Restli-Protocol-Version": "2.0.0"
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
async def get_user_profile(self) -> Dict[str, Any]:
|
| 18 |
+
"""Get LinkedIn user profile"""
|
| 19 |
+
async with httpx.AsyncClient() as client:
|
| 20 |
+
response = await client.get(
|
| 21 |
+
f"{LINKEDIN_API_BASE}/userinfo",
|
| 22 |
+
headers=self.headers,
|
| 23 |
+
timeout=30.0
|
| 24 |
+
)
|
| 25 |
+
response.raise_for_status()
|
| 26 |
+
return response.json()
|
| 27 |
+
|
| 28 |
+
async def create_post(self, text: str, media_uris: Optional[list] = None) -> Dict[str, Any]:
|
| 29 |
+
"""Create a LinkedIn post"""
|
| 30 |
+
# First, register upload if media is provided
|
| 31 |
+
media_urn = None
|
| 32 |
+
if media_uris:
|
| 33 |
+
# Register media upload
|
| 34 |
+
media_urn = await self._register_upload(media_uris[0])
|
| 35 |
+
|
| 36 |
+
# Create the post
|
| 37 |
+
post_data = {
|
| 38 |
+
"author": f"urn:li:person:{os.getenv('LINKEDIN_PERSON_URN', '')}",
|
| 39 |
+
"lifecycleState": "PUBLISHED",
|
| 40 |
+
"specificContent": {
|
| 41 |
+
"com.linkedin.ugc.ShareContent": {
|
| 42 |
+
"shareCommentary": {
|
| 43 |
+
"text": text
|
| 44 |
+
},
|
| 45 |
+
"shareMediaCategory": "IMAGE" if media_urn else "NONE"
|
| 46 |
+
}
|
| 47 |
+
},
|
| 48 |
+
"visibility": {
|
| 49 |
+
"com.linkedin.ugc.MemberNetworkVisibility": "PUBLIC"
|
| 50 |
+
}
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
if media_urn:
|
| 54 |
+
post_data["specificContent"]["com.linkedin.ugc.ShareContent"]["media"] = [
|
| 55 |
+
{
|
| 56 |
+
"status": "READY",
|
| 57 |
+
"media": media_urn
|
| 58 |
+
}
|
| 59 |
+
]
|
| 60 |
+
|
| 61 |
+
async with httpx.AsyncClient() as client:
|
| 62 |
+
response = await client.post(
|
| 63 |
+
f"{LINKEDIN_API_BASE}/ugcPosts",
|
| 64 |
+
headers=self.headers,
|
| 65 |
+
json=post_data,
|
| 66 |
+
timeout=30.0
|
| 67 |
+
)
|
| 68 |
+
response.raise_for_status()
|
| 69 |
+
return response.json()
|
| 70 |
+
|
| 71 |
+
async def _register_upload(self, image_url: str) -> str:
|
| 72 |
+
"""Register an image upload for LinkedIn"""
|
| 73 |
+
# This is a simplified version - actual implementation would upload the image
|
| 74 |
+
# and get a URN back
|
| 75 |
+
async with httpx.AsyncClient() as client:
|
| 76 |
+
# Register upload
|
| 77 |
+
register_response = await client.post(
|
| 78 |
+
f"{LINKEDIN_API_BASE}/assets?action=registerUpload",
|
| 79 |
+
headers=self.headers,
|
| 80 |
+
json={
|
| 81 |
+
"registerUploadRequest": {
|
| 82 |
+
"recipes": ["urn:li:digitalmediaRecipe:feedshare-image"],
|
| 83 |
+
"owner": f"urn:li:person:{os.getenv('LINKEDIN_PERSON_URN', '')}",
|
| 84 |
+
"serviceRelationships": [
|
| 85 |
+
{
|
| 86 |
+
"relationshipType": "OWNER",
|
| 87 |
+
"identifier": "urn:li:userGeneratedContent"
|
| 88 |
+
}
|
| 89 |
+
]
|
| 90 |
+
}
|
| 91 |
+
},
|
| 92 |
+
timeout=30.0
|
| 93 |
+
)
|
| 94 |
+
register_response.raise_for_status()
|
| 95 |
+
register_data = register_response.json()
|
| 96 |
+
|
| 97 |
+
# Upload the image
|
| 98 |
+
upload_url = register_data["value"]["uploadMechanism"]["com.linkedin.digitalmedia.uploading.MediaUploadHttpRequest"]["uploadUrl"]
|
| 99 |
+
asset_urn = register_data["value"]["asset"]
|
| 100 |
+
|
| 101 |
+
# Download and upload the image
|
| 102 |
+
image_response = await client.get(image_url, timeout=30.0)
|
| 103 |
+
image_response.raise_for_status()
|
| 104 |
+
|
| 105 |
+
await client.put(
|
| 106 |
+
upload_url,
|
| 107 |
+
content=image_response.content,
|
| 108 |
+
headers={"Content-Type": "application/octet-stream"},
|
| 109 |
+
timeout=30.0
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
return asset_urn
|
| 113 |
+
|
backend/requirements.txt
CHANGED
|
@@ -1,2 +1,13 @@
|
|
| 1 |
fastapi
|
| 2 |
-
uvicorn
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
fastapi
|
| 2 |
+
uvicorn[standard]
|
| 3 |
+
python-multipart
|
| 4 |
+
aiofiles
|
| 5 |
+
httpx
|
| 6 |
+
openai
|
| 7 |
+
python-dotenv
|
| 8 |
+
pydantic
|
| 9 |
+
python-jose[cryptography]
|
| 10 |
+
passlib[bcrypt]
|
| 11 |
+
sqlalchemy
|
| 12 |
+
alembic
|
| 13 |
+
psycopg2-binary
|
frontend/index.html
CHANGED
|
@@ -3,7 +3,7 @@
|
|
| 3 |
<head>
|
| 4 |
<meta charset="UTF-8" />
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
-
<title>
|
| 7 |
</head>
|
| 8 |
<body>
|
| 9 |
<div id="root"></div>
|
|
|
|
| 3 |
<head>
|
| 4 |
<meta charset="UTF-8" />
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<title>PostGen - LinkedIn Content Scheduler</title>
|
| 7 |
</head>
|
| 8 |
<body>
|
| 9 |
<div id="root"></div>
|
frontend/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
{
|
| 2 |
-
"name": "
|
| 3 |
"private": true,
|
| 4 |
"version": "0.0.1",
|
| 5 |
"type": "module",
|
|
@@ -10,10 +10,35 @@
|
|
| 10 |
},
|
| 11 |
"dependencies": {
|
| 12 |
"react": "^18.3.1",
|
| 13 |
-
"react-dom": "^18.3.1"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
},
|
| 15 |
"devDependencies": {
|
| 16 |
"@vitejs/plugin-react": "^4.3.1",
|
| 17 |
-
"vite": "^5.4.2"
|
|
|
|
|
|
|
|
|
|
| 18 |
}
|
| 19 |
}
|
|
|
|
| 1 |
{
|
| 2 |
+
"name": "postgen",
|
| 3 |
"private": true,
|
| 4 |
"version": "0.0.1",
|
| 5 |
"type": "module",
|
|
|
|
| 10 |
},
|
| 11 |
"dependencies": {
|
| 12 |
"react": "^18.3.1",
|
| 13 |
+
"react-dom": "^18.3.1",
|
| 14 |
+
"react-router-dom": "^6.26.0",
|
| 15 |
+
"framer-motion": "^11.0.0",
|
| 16 |
+
"lucide-react": "^0.344.0",
|
| 17 |
+
"date-fns": "^3.3.1",
|
| 18 |
+
"clsx": "^2.1.0",
|
| 19 |
+
"tailwind-merge": "^2.2.1",
|
| 20 |
+
"@radix-ui/react-dialog": "^1.0.5",
|
| 21 |
+
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
| 22 |
+
"@radix-ui/react-label": "^2.0.2",
|
| 23 |
+
"@radix-ui/react-popover": "^1.0.7",
|
| 24 |
+
"@radix-ui/react-select": "^2.0.0",
|
| 25 |
+
"@radix-ui/react-separator": "^1.0.3",
|
| 26 |
+
"@radix-ui/react-slot": "^1.0.2",
|
| 27 |
+
"@radix-ui/react-switch": "^1.0.3",
|
| 28 |
+
"@radix-ui/react-tabs": "^1.0.4",
|
| 29 |
+
"@radix-ui/react-avatar": "^1.0.4",
|
| 30 |
+
"@radix-ui/react-checkbox": "^1.0.4",
|
| 31 |
+
"@radix-ui/react-slider": "^1.1.2",
|
| 32 |
+
"@radix-ui/react-alert-dialog": "^1.0.5",
|
| 33 |
+
"@radix-ui/react-progress": "^1.0.3",
|
| 34 |
+
"class-variance-authority": "^0.7.0",
|
| 35 |
+
"react-day-picker": "^8.10.0"
|
| 36 |
},
|
| 37 |
"devDependencies": {
|
| 38 |
"@vitejs/plugin-react": "^4.3.1",
|
| 39 |
+
"vite": "^5.4.2",
|
| 40 |
+
"autoprefixer": "^10.4.17",
|
| 41 |
+
"postcss": "^8.4.33",
|
| 42 |
+
"tailwindcss": "^3.4.1"
|
| 43 |
}
|
| 44 |
}
|
frontend/postcss.config.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export default {
|
| 2 |
+
plugins: {
|
| 3 |
+
tailwindcss: {},
|
| 4 |
+
autoprefixer: {},
|
| 5 |
+
},
|
| 6 |
+
}
|
| 7 |
+
|
frontend/src/App.jsx
CHANGED
|
@@ -1,23 +1,25 @@
|
|
| 1 |
-
import React
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
.catch(() => setApiMsg("API not reachable yet"));
|
| 11 |
-
}, []);
|
| 12 |
|
|
|
|
| 13 |
return (
|
| 14 |
-
<
|
| 15 |
-
<
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
<
|
| 20 |
-
|
| 21 |
-
|
|
|
|
| 22 |
);
|
| 23 |
}
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from "react";
|
| 2 |
+
import { BrowserRouter as Router, Routes, Route } from "react-router-dom";
|
| 3 |
+
import Layout from "./components/Layout";
|
| 4 |
+
import Dashboard from "./pages/Dashboard";
|
| 5 |
+
import Repository from "./pages/Repository";
|
| 6 |
+
import Scheduler from "./pages/Scheduler";
|
| 7 |
+
import PostEditor from "./pages/PostEditor";
|
| 8 |
+
import Integrations from "./pages/Integrations";
|
| 9 |
+
import { createPageUrl } from "./utils";
|
|
|
|
|
|
|
| 10 |
|
| 11 |
+
function App() {
|
| 12 |
return (
|
| 13 |
+
<Router>
|
| 14 |
+
<Routes>
|
| 15 |
+
<Route path="/" element={<Layout currentPageName="Dashboard"><Dashboard /></Layout>} />
|
| 16 |
+
<Route path={createPageUrl("Repository")} element={<Layout currentPageName="Repository"><Repository /></Layout>} />
|
| 17 |
+
<Route path={createPageUrl("Scheduler")} element={<Layout currentPageName="Scheduler"><Scheduler /></Layout>} />
|
| 18 |
+
<Route path={createPageUrl("PostEditor")} element={<Layout currentPageName="PostEditor"><PostEditor /></Layout>} />
|
| 19 |
+
<Route path={createPageUrl("Integrations")} element={<Layout currentPageName="Integrations"><Integrations /></Layout>} />
|
| 20 |
+
</Routes>
|
| 21 |
+
</Router>
|
| 22 |
);
|
| 23 |
}
|
| 24 |
+
|
| 25 |
+
export default App;
|
frontend/src/components/Layout.jsx
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState } from 'react';
|
| 2 |
+
import { Link, useLocation } from 'react-router-dom';
|
| 3 |
+
import { createPageUrl } from '@/utils';
|
| 4 |
+
import { motion, AnimatePresence } from 'framer-motion';
|
| 5 |
+
import {
|
| 6 |
+
LayoutDashboard,
|
| 7 |
+
FolderOpen,
|
| 8 |
+
Calendar,
|
| 9 |
+
PenTool,
|
| 10 |
+
Link2,
|
| 11 |
+
Settings,
|
| 12 |
+
Menu,
|
| 13 |
+
X,
|
| 14 |
+
Bell,
|
| 15 |
+
Search,
|
| 16 |
+
ChevronDown,
|
| 17 |
+
Linkedin,
|
| 18 |
+
Sparkles,
|
| 19 |
+
LogOut,
|
| 20 |
+
User,
|
| 21 |
+
HelpCircle
|
| 22 |
+
} from 'lucide-react';
|
| 23 |
+
import { Button } from '@/components/ui/button';
|
| 24 |
+
import { Input } from '@/components/ui/input';
|
| 25 |
+
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
| 26 |
+
import { Badge } from '@/components/ui/badge';
|
| 27 |
+
import {
|
| 28 |
+
DropdownMenu,
|
| 29 |
+
DropdownMenuContent,
|
| 30 |
+
DropdownMenuItem,
|
| 31 |
+
DropdownMenuLabel,
|
| 32 |
+
DropdownMenuSeparator,
|
| 33 |
+
DropdownMenuTrigger,
|
| 34 |
+
} from '@/components/ui/dropdown-menu';
|
| 35 |
+
|
| 36 |
+
const navItems = [
|
| 37 |
+
{ name: 'Dashboard', icon: LayoutDashboard, href: 'Dashboard' },
|
| 38 |
+
{ name: 'Repository', icon: FolderOpen, href: 'Repository' },
|
| 39 |
+
{ name: 'Scheduler', icon: Calendar, href: 'Scheduler' },
|
| 40 |
+
{ name: 'Post Editor', icon: PenTool, href: 'PostEditor' },
|
| 41 |
+
{ name: 'Integrations', icon: Link2, href: 'Integrations' },
|
| 42 |
+
];
|
| 43 |
+
|
| 44 |
+
export default function Layout({ children, currentPageName }) {
|
| 45 |
+
const [sidebarOpen, setSidebarOpen] = useState(false);
|
| 46 |
+
const location = useLocation();
|
| 47 |
+
const currentPage = navItems.find(item => createPageUrl(item.href) === location.pathname)?.href || 'Dashboard';
|
| 48 |
+
|
| 49 |
+
return (
|
| 50 |
+
<div className="min-h-screen bg-slate-50">
|
| 51 |
+
{/* Mobile Sidebar Overlay */}
|
| 52 |
+
<AnimatePresence>
|
| 53 |
+
{sidebarOpen && (
|
| 54 |
+
<motion.div
|
| 55 |
+
initial={{ opacity: 0 }}
|
| 56 |
+
animate={{ opacity: 1 }}
|
| 57 |
+
exit={{ opacity: 0 }}
|
| 58 |
+
className="fixed inset-0 bg-black/50 z-40 lg:hidden"
|
| 59 |
+
onClick={() => setSidebarOpen(false)}
|
| 60 |
+
/>
|
| 61 |
+
)}
|
| 62 |
+
</AnimatePresence>
|
| 63 |
+
|
| 64 |
+
{/* Sidebar */}
|
| 65 |
+
<aside className={`fixed top-0 left-0 z-50 h-full w-64 bg-white border-r border-slate-200 transform transition-transform duration-300 ease-in-out lg:translate-x-0 ${
|
| 66 |
+
sidebarOpen ? 'translate-x-0' : '-translate-x-full'
|
| 67 |
+
}`}>
|
| 68 |
+
<div className="flex flex-col h-full">
|
| 69 |
+
{/* Logo */}
|
| 70 |
+
<div className="h-16 flex items-center justify-between px-4 border-b border-slate-100">
|
| 71 |
+
<Link to={createPageUrl('Dashboard')} className="flex items-center gap-2">
|
| 72 |
+
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-blue-600 to-indigo-600 flex items-center justify-center shadow-lg shadow-blue-500/30">
|
| 73 |
+
<Linkedin className="w-5 h-5 text-white" />
|
| 74 |
+
</div>
|
| 75 |
+
<div>
|
| 76 |
+
<span className="font-bold text-slate-900 text-lg">PostGen</span>
|
| 77 |
+
<span className="text-xs text-slate-500 block -mt-1">LinkedIn Scheduler</span>
|
| 78 |
+
</div>
|
| 79 |
+
</Link>
|
| 80 |
+
<Button
|
| 81 |
+
variant="ghost"
|
| 82 |
+
size="icon"
|
| 83 |
+
className="lg:hidden"
|
| 84 |
+
onClick={() => setSidebarOpen(false)}
|
| 85 |
+
>
|
| 86 |
+
<X className="w-5 h-5" />
|
| 87 |
+
</Button>
|
| 88 |
+
</div>
|
| 89 |
+
|
| 90 |
+
{/* Navigation */}
|
| 91 |
+
<nav className="flex-1 p-4 space-y-1 overflow-y-auto">
|
| 92 |
+
{navItems.map((item) => {
|
| 93 |
+
const isActive = currentPage === item.href;
|
| 94 |
+
return (
|
| 95 |
+
<Link
|
| 96 |
+
key={item.name}
|
| 97 |
+
to={createPageUrl(item.href)}
|
| 98 |
+
onClick={() => setSidebarOpen(false)}
|
| 99 |
+
className={`flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all ${
|
| 100 |
+
isActive
|
| 101 |
+
? 'bg-blue-50 text-blue-700'
|
| 102 |
+
: 'text-slate-600 hover:bg-slate-50 hover:text-slate-900'
|
| 103 |
+
}`}
|
| 104 |
+
>
|
| 105 |
+
<item.icon className={`w-5 h-5 ${isActive ? 'text-blue-600' : 'text-slate-400'}`} />
|
| 106 |
+
{item.name}
|
| 107 |
+
{item.name === 'Repository' && (
|
| 108 |
+
<Badge className="ml-auto bg-blue-100 text-blue-700 text-[10px] px-1.5 py-0 border-0">
|
| 109 |
+
156
|
| 110 |
+
</Badge>
|
| 111 |
+
)}
|
| 112 |
+
</Link>
|
| 113 |
+
);
|
| 114 |
+
})}
|
| 115 |
+
</nav>
|
| 116 |
+
|
| 117 |
+
{/* Quick Create */}
|
| 118 |
+
<div className="p-4 border-t border-slate-100">
|
| 119 |
+
<Link to={createPageUrl('PostEditor')}>
|
| 120 |
+
<Button className="w-full gap-2 bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700 shadow-lg shadow-blue-500/25">
|
| 121 |
+
<Sparkles className="w-4 h-4" />
|
| 122 |
+
Create Post
|
| 123 |
+
</Button>
|
| 124 |
+
</Link>
|
| 125 |
+
</div>
|
| 126 |
+
|
| 127 |
+
{/* User Profile */}
|
| 128 |
+
<div className="p-4 border-t border-slate-100">
|
| 129 |
+
<DropdownMenu>
|
| 130 |
+
<DropdownMenuTrigger asChild>
|
| 131 |
+
<button className="flex items-center gap-3 w-full p-2 rounded-xl hover:bg-slate-50 transition-colors">
|
| 132 |
+
<Avatar className="h-9 w-9">
|
| 133 |
+
<AvatarImage src="https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=100&h=100&fit=crop" />
|
| 134 |
+
<AvatarFallback>AB</AvatarFallback>
|
| 135 |
+
</Avatar>
|
| 136 |
+
<div className="flex-1 text-left">
|
| 137 |
+
<p className="text-sm font-medium text-slate-900">Alex Business</p>
|
| 138 |
+
<p className="text-xs text-slate-500">Pro Plan</p>
|
| 139 |
+
</div>
|
| 140 |
+
<ChevronDown className="w-4 h-4 text-slate-400" />
|
| 141 |
+
</button>
|
| 142 |
+
</DropdownMenuTrigger>
|
| 143 |
+
<DropdownMenuContent align="end" className="w-56">
|
| 144 |
+
<DropdownMenuLabel>My Account</DropdownMenuLabel>
|
| 145 |
+
<DropdownMenuSeparator />
|
| 146 |
+
<DropdownMenuItem>
|
| 147 |
+
<User className="w-4 h-4 mr-2" />
|
| 148 |
+
Profile Settings
|
| 149 |
+
</DropdownMenuItem>
|
| 150 |
+
<DropdownMenuItem>
|
| 151 |
+
<Settings className="w-4 h-4 mr-2" />
|
| 152 |
+
Preferences
|
| 153 |
+
</DropdownMenuItem>
|
| 154 |
+
<DropdownMenuItem>
|
| 155 |
+
<HelpCircle className="w-4 h-4 mr-2" />
|
| 156 |
+
Help & Support
|
| 157 |
+
</DropdownMenuItem>
|
| 158 |
+
<DropdownMenuSeparator />
|
| 159 |
+
<DropdownMenuItem className="text-red-600">
|
| 160 |
+
<LogOut className="w-4 h-4 mr-2" />
|
| 161 |
+
Sign Out
|
| 162 |
+
</DropdownMenuItem>
|
| 163 |
+
</DropdownMenuContent>
|
| 164 |
+
</DropdownMenu>
|
| 165 |
+
</div>
|
| 166 |
+
</div>
|
| 167 |
+
</aside>
|
| 168 |
+
|
| 169 |
+
{/* Main Content */}
|
| 170 |
+
<div className="lg:pl-64">
|
| 171 |
+
{/* Top Header */}
|
| 172 |
+
<header className="h-16 bg-white border-b border-slate-200 sticky top-0 z-30">
|
| 173 |
+
<div className="h-full px-4 flex items-center justify-between gap-4">
|
| 174 |
+
{/* Mobile Menu Toggle */}
|
| 175 |
+
<Button
|
| 176 |
+
variant="ghost"
|
| 177 |
+
size="icon"
|
| 178 |
+
className="lg:hidden"
|
| 179 |
+
onClick={() => setSidebarOpen(true)}
|
| 180 |
+
>
|
| 181 |
+
<Menu className="w-5 h-5" />
|
| 182 |
+
</Button>
|
| 183 |
+
|
| 184 |
+
{/* Search */}
|
| 185 |
+
<div className="flex-1 max-w-md hidden sm:block">
|
| 186 |
+
<div className="relative">
|
| 187 |
+
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
| 188 |
+
<Input
|
| 189 |
+
placeholder="Search posts, assets, schedules..."
|
| 190 |
+
className="pl-10 bg-slate-50 border-slate-200 focus:bg-white"
|
| 191 |
+
/>
|
| 192 |
+
</div>
|
| 193 |
+
</div>
|
| 194 |
+
|
| 195 |
+
{/* Right Actions */}
|
| 196 |
+
<div className="flex items-center gap-2">
|
| 197 |
+
<Button variant="ghost" size="icon" className="relative">
|
| 198 |
+
<Bell className="w-5 h-5 text-slate-600" />
|
| 199 |
+
<span className="absolute top-1.5 right-1.5 w-2 h-2 bg-red-500 rounded-full" />
|
| 200 |
+
</Button>
|
| 201 |
+
|
| 202 |
+
{/* Connection Status */}
|
| 203 |
+
<div className="hidden md:flex items-center gap-2 px-3 py-1.5 rounded-lg bg-emerald-50 border border-emerald-100">
|
| 204 |
+
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" />
|
| 205 |
+
<span className="text-xs font-medium text-emerald-700">LinkedIn Connected</span>
|
| 206 |
+
</div>
|
| 207 |
+
</div>
|
| 208 |
+
</div>
|
| 209 |
+
</header>
|
| 210 |
+
|
| 211 |
+
{/* Page Content */}
|
| 212 |
+
<main>
|
| 213 |
+
{children}
|
| 214 |
+
</main>
|
| 215 |
+
</div>
|
| 216 |
+
</div>
|
| 217 |
+
);
|
| 218 |
+
}
|
| 219 |
+
|
frontend/src/components/ui/alert.jsx
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react";
|
| 2 |
+
import { cva } from "class-variance-authority";
|
| 3 |
+
import { cn } from "@/utils";
|
| 4 |
+
|
| 5 |
+
const alertVariants = cva(
|
| 6 |
+
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
|
| 7 |
+
{
|
| 8 |
+
variants: {
|
| 9 |
+
variant: {
|
| 10 |
+
default: "bg-background text-foreground",
|
| 11 |
+
destructive:
|
| 12 |
+
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
| 13 |
+
},
|
| 14 |
+
},
|
| 15 |
+
defaultVariants: {
|
| 16 |
+
variant: "default",
|
| 17 |
+
},
|
| 18 |
+
}
|
| 19 |
+
);
|
| 20 |
+
|
| 21 |
+
const Alert = React.forwardRef(({ className, variant, ...props }, ref) => (
|
| 22 |
+
<div
|
| 23 |
+
ref={ref}
|
| 24 |
+
role="alert"
|
| 25 |
+
className={cn(alertVariants({ variant }), className)}
|
| 26 |
+
{...props}
|
| 27 |
+
/>
|
| 28 |
+
));
|
| 29 |
+
Alert.displayName = "Alert";
|
| 30 |
+
|
| 31 |
+
const AlertTitle = React.forwardRef(({ className, ...props }, ref) => (
|
| 32 |
+
<h5
|
| 33 |
+
ref={ref}
|
| 34 |
+
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
| 35 |
+
{...props}
|
| 36 |
+
/>
|
| 37 |
+
));
|
| 38 |
+
AlertTitle.displayName = "AlertTitle";
|
| 39 |
+
|
| 40 |
+
const AlertDescription = React.forwardRef(({ className, ...props }, ref) => (
|
| 41 |
+
<div
|
| 42 |
+
ref={ref}
|
| 43 |
+
className={cn("text-sm [&_p]:leading-relaxed", className)}
|
| 44 |
+
{...props}
|
| 45 |
+
/>
|
| 46 |
+
));
|
| 47 |
+
AlertDescription.displayName = "AlertDescription";
|
| 48 |
+
|
| 49 |
+
export { Alert, AlertTitle, AlertDescription };
|
| 50 |
+
|
frontend/src/components/ui/avatar.jsx
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react";
|
| 2 |
+
import * as AvatarPrimitive from "@radix-ui/react-avatar";
|
| 3 |
+
import { cn } from "@/utils";
|
| 4 |
+
|
| 5 |
+
const Avatar = React.forwardRef(({ className, ...props }, ref) => (
|
| 6 |
+
<AvatarPrimitive.Root
|
| 7 |
+
ref={ref}
|
| 8 |
+
className={cn(
|
| 9 |
+
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
|
| 10 |
+
className
|
| 11 |
+
)}
|
| 12 |
+
{...props}
|
| 13 |
+
/>
|
| 14 |
+
));
|
| 15 |
+
Avatar.displayName = AvatarPrimitive.Root.displayName;
|
| 16 |
+
|
| 17 |
+
const AvatarImage = React.forwardRef(({ className, ...props }, ref) => (
|
| 18 |
+
<AvatarPrimitive.Image
|
| 19 |
+
ref={ref}
|
| 20 |
+
className={cn("aspect-square h-full w-full", className)}
|
| 21 |
+
{...props}
|
| 22 |
+
/>
|
| 23 |
+
));
|
| 24 |
+
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
|
| 25 |
+
|
| 26 |
+
const AvatarFallback = React.forwardRef(({ className, ...props }, ref) => (
|
| 27 |
+
<AvatarPrimitive.Fallback
|
| 28 |
+
ref={ref}
|
| 29 |
+
className={cn(
|
| 30 |
+
"flex h-full w-full items-center justify-center rounded-full bg-muted",
|
| 31 |
+
className
|
| 32 |
+
)}
|
| 33 |
+
{...props}
|
| 34 |
+
/>
|
| 35 |
+
));
|
| 36 |
+
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
|
| 37 |
+
|
| 38 |
+
export { Avatar, AvatarImage, AvatarFallback };
|
| 39 |
+
|
frontend/src/components/ui/badge.jsx
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react";
|
| 2 |
+
import { cva } from "class-variance-authority";
|
| 3 |
+
import { cn } from "@/utils";
|
| 4 |
+
|
| 5 |
+
const badgeVariants = cva(
|
| 6 |
+
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
| 7 |
+
{
|
| 8 |
+
variants: {
|
| 9 |
+
variant: {
|
| 10 |
+
default:
|
| 11 |
+
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
| 12 |
+
secondary:
|
| 13 |
+
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
| 14 |
+
destructive:
|
| 15 |
+
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
| 16 |
+
outline: "text-foreground",
|
| 17 |
+
},
|
| 18 |
+
},
|
| 19 |
+
defaultVariants: {
|
| 20 |
+
variant: "default",
|
| 21 |
+
},
|
| 22 |
+
}
|
| 23 |
+
);
|
| 24 |
+
|
| 25 |
+
function Badge({ className, variant, ...props }) {
|
| 26 |
+
return (
|
| 27 |
+
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
| 28 |
+
);
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
export { Badge, badgeVariants };
|
| 32 |
+
|
frontend/src/components/ui/button.jsx
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react";
|
| 2 |
+
import { Slot } from "@radix-ui/react-slot";
|
| 3 |
+
import { cva } from "class-variance-authority";
|
| 4 |
+
import { cn } from "@/utils";
|
| 5 |
+
|
| 6 |
+
const buttonVariants = cva(
|
| 7 |
+
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
| 8 |
+
{
|
| 9 |
+
variants: {
|
| 10 |
+
variant: {
|
| 11 |
+
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
| 12 |
+
destructive:
|
| 13 |
+
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
| 14 |
+
outline:
|
| 15 |
+
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
| 16 |
+
secondary:
|
| 17 |
+
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
| 18 |
+
ghost: "hover:bg-accent hover:text-accent-foreground",
|
| 19 |
+
link: "text-primary underline-offset-4 hover:underline",
|
| 20 |
+
},
|
| 21 |
+
size: {
|
| 22 |
+
default: "h-10 px-4 py-2",
|
| 23 |
+
sm: "h-9 rounded-md px-3",
|
| 24 |
+
lg: "h-11 rounded-md px-8",
|
| 25 |
+
icon: "h-10 w-10",
|
| 26 |
+
},
|
| 27 |
+
},
|
| 28 |
+
defaultVariants: {
|
| 29 |
+
variant: "default",
|
| 30 |
+
size: "default",
|
| 31 |
+
},
|
| 32 |
+
}
|
| 33 |
+
);
|
| 34 |
+
|
| 35 |
+
const Button = React.forwardRef(
|
| 36 |
+
({ className, variant, size, asChild = false, ...props }, ref) => {
|
| 37 |
+
const Comp = asChild ? Slot : "button";
|
| 38 |
+
return (
|
| 39 |
+
<Comp
|
| 40 |
+
className={cn(buttonVariants({ variant, size, className }))}
|
| 41 |
+
ref={ref}
|
| 42 |
+
{...props}
|
| 43 |
+
/>
|
| 44 |
+
);
|
| 45 |
+
}
|
| 46 |
+
);
|
| 47 |
+
Button.displayName = "Button";
|
| 48 |
+
|
| 49 |
+
export { Button, buttonVariants };
|
| 50 |
+
|
frontend/src/components/ui/calendar.jsx
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react";
|
| 2 |
+
import { ChevronLeft, ChevronRight } from "lucide-react";
|
| 3 |
+
import { DayPicker } from "react-day-picker";
|
| 4 |
+
import { cn } from "@/utils";
|
| 5 |
+
|
| 6 |
+
export function Calendar({ className, classNames, showOutsideDays = true, mode = "single", selected, onSelect, ...props }) {
|
| 7 |
+
return (
|
| 8 |
+
<DayPicker
|
| 9 |
+
mode={mode}
|
| 10 |
+
selected={selected}
|
| 11 |
+
onSelect={onSelect}
|
| 12 |
+
showOutsideDays={showOutsideDays}
|
| 13 |
+
className={cn("p-3", className)}
|
| 14 |
+
classNames={{
|
| 15 |
+
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
|
| 16 |
+
month: "space-y-4",
|
| 17 |
+
caption: "flex justify-center pt-1 relative items-center",
|
| 18 |
+
caption_label: "text-sm font-medium",
|
| 19 |
+
nav: "space-x-1 flex items-center",
|
| 20 |
+
nav_button: cn(
|
| 21 |
+
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100"
|
| 22 |
+
),
|
| 23 |
+
nav_button_previous: "absolute left-1",
|
| 24 |
+
nav_button_next: "absolute right-1",
|
| 25 |
+
table: "w-full border-collapse space-y-1",
|
| 26 |
+
head_row: "flex",
|
| 27 |
+
head_cell:
|
| 28 |
+
"text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]",
|
| 29 |
+
row: "flex w-full mt-2",
|
| 30 |
+
cell: "h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20",
|
| 31 |
+
day: cn(
|
| 32 |
+
"h-9 w-9 p-0 font-normal aria-selected:opacity-100"
|
| 33 |
+
),
|
| 34 |
+
day_range_end: "day-range-end",
|
| 35 |
+
day_selected:
|
| 36 |
+
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
|
| 37 |
+
day_today: "bg-accent text-accent-foreground",
|
| 38 |
+
day_outside:
|
| 39 |
+
"day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30",
|
| 40 |
+
day_disabled: "text-muted-foreground opacity-50",
|
| 41 |
+
day_range_middle:
|
| 42 |
+
"aria-selected:bg-accent aria-selected:text-accent-foreground",
|
| 43 |
+
day_hidden: "invisible",
|
| 44 |
+
...classNames,
|
| 45 |
+
}}
|
| 46 |
+
components={{
|
| 47 |
+
IconLeft: ({ ...props }) => <ChevronLeft className="h-4 w-4" />,
|
| 48 |
+
IconRight: ({ ...props }) => <ChevronRight className="h-4 w-4" />,
|
| 49 |
+
}}
|
| 50 |
+
{...props}
|
| 51 |
+
/>
|
| 52 |
+
);
|
| 53 |
+
}
|
| 54 |
+
|
frontend/src/components/ui/card.jsx
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react";
|
| 2 |
+
import { cn } from "@/utils";
|
| 3 |
+
|
| 4 |
+
const Card = React.forwardRef(({ className, ...props }, ref) => (
|
| 5 |
+
<div
|
| 6 |
+
ref={ref}
|
| 7 |
+
className={cn(
|
| 8 |
+
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
| 9 |
+
className
|
| 10 |
+
)}
|
| 11 |
+
{...props}
|
| 12 |
+
/>
|
| 13 |
+
));
|
| 14 |
+
Card.displayName = "Card";
|
| 15 |
+
|
| 16 |
+
const CardHeader = React.forwardRef(({ className, ...props }, ref) => (
|
| 17 |
+
<div
|
| 18 |
+
ref={ref}
|
| 19 |
+
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
| 20 |
+
{...props}
|
| 21 |
+
/>
|
| 22 |
+
));
|
| 23 |
+
CardHeader.displayName = "CardHeader";
|
| 24 |
+
|
| 25 |
+
const CardTitle = React.forwardRef(({ className, ...props }, ref) => (
|
| 26 |
+
<h3
|
| 27 |
+
ref={ref}
|
| 28 |
+
className={cn(
|
| 29 |
+
"text-2xl font-semibold leading-none tracking-tight",
|
| 30 |
+
className
|
| 31 |
+
)}
|
| 32 |
+
{...props}
|
| 33 |
+
/>
|
| 34 |
+
));
|
| 35 |
+
CardTitle.displayName = "CardTitle";
|
| 36 |
+
|
| 37 |
+
const CardDescription = React.forwardRef(({ className, ...props }, ref) => (
|
| 38 |
+
<p
|
| 39 |
+
ref={ref}
|
| 40 |
+
className={cn("text-sm text-muted-foreground", className)}
|
| 41 |
+
{...props}
|
| 42 |
+
/>
|
| 43 |
+
));
|
| 44 |
+
CardDescription.displayName = "CardDescription";
|
| 45 |
+
|
| 46 |
+
const CardContent = React.forwardRef(({ className, ...props }, ref) => (
|
| 47 |
+
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
| 48 |
+
));
|
| 49 |
+
CardContent.displayName = "CardContent";
|
| 50 |
+
|
| 51 |
+
const CardFooter = React.forwardRef(({ className, ...props }, ref) => (
|
| 52 |
+
<div
|
| 53 |
+
ref={ref}
|
| 54 |
+
className={cn("flex items-center p-6 pt-0", className)}
|
| 55 |
+
{...props}
|
| 56 |
+
/>
|
| 57 |
+
));
|
| 58 |
+
CardFooter.displayName = "CardFooter";
|
| 59 |
+
|
| 60 |
+
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
| 61 |
+
|
frontend/src/components/ui/checkbox.jsx
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react";
|
| 2 |
+
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
| 3 |
+
import { Check } from "lucide-react";
|
| 4 |
+
import { cn } from "@/utils";
|
| 5 |
+
|
| 6 |
+
const Checkbox = React.forwardRef(({ className, ...props }, ref) => (
|
| 7 |
+
<CheckboxPrimitive.Root
|
| 8 |
+
ref={ref}
|
| 9 |
+
className={cn(
|
| 10 |
+
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
| 11 |
+
className
|
| 12 |
+
)}
|
| 13 |
+
{...props}
|
| 14 |
+
>
|
| 15 |
+
<CheckboxPrimitive.Indicator
|
| 16 |
+
className={cn("flex items-center justify-center text-current")}
|
| 17 |
+
>
|
| 18 |
+
<Check className="h-4 w-4" />
|
| 19 |
+
</CheckboxPrimitive.Indicator>
|
| 20 |
+
</CheckboxPrimitive.Root>
|
| 21 |
+
));
|
| 22 |
+
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
|
| 23 |
+
|
| 24 |
+
export { Checkbox };
|
| 25 |
+
|
frontend/src/components/ui/dialog.jsx
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react";
|
| 2 |
+
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
| 3 |
+
import { X } from "lucide-react";
|
| 4 |
+
import { cn } from "@/utils";
|
| 5 |
+
|
| 6 |
+
const Dialog = DialogPrimitive.Root;
|
| 7 |
+
const DialogTrigger = DialogPrimitive.Trigger;
|
| 8 |
+
const DialogPortal = DialogPrimitive.Portal;
|
| 9 |
+
const DialogClose = DialogPrimitive.Close;
|
| 10 |
+
|
| 11 |
+
const DialogOverlay = React.forwardRef(({ className, ...props }, ref) => (
|
| 12 |
+
<DialogPrimitive.Overlay
|
| 13 |
+
ref={ref}
|
| 14 |
+
className={cn(
|
| 15 |
+
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
| 16 |
+
className
|
| 17 |
+
)}
|
| 18 |
+
{...props}
|
| 19 |
+
/>
|
| 20 |
+
));
|
| 21 |
+
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
| 22 |
+
|
| 23 |
+
const DialogContent = React.forwardRef(({ className, children, ...props }, ref) => (
|
| 24 |
+
<DialogPortal>
|
| 25 |
+
<DialogOverlay />
|
| 26 |
+
<DialogPrimitive.Content
|
| 27 |
+
ref={ref}
|
| 28 |
+
className={cn(
|
| 29 |
+
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
| 30 |
+
className
|
| 31 |
+
)}
|
| 32 |
+
{...props}
|
| 33 |
+
>
|
| 34 |
+
{children}
|
| 35 |
+
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
| 36 |
+
<X className="h-4 w-4" />
|
| 37 |
+
<span className="sr-only">Close</span>
|
| 38 |
+
</DialogPrimitive.Close>
|
| 39 |
+
</DialogPrimitive.Content>
|
| 40 |
+
</DialogPortal>
|
| 41 |
+
));
|
| 42 |
+
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
| 43 |
+
|
| 44 |
+
const DialogHeader = ({ className, ...props }) => (
|
| 45 |
+
<div
|
| 46 |
+
className={cn(
|
| 47 |
+
"flex flex-col space-y-1.5 text-center sm:text-left",
|
| 48 |
+
className
|
| 49 |
+
)}
|
| 50 |
+
{...props}
|
| 51 |
+
/>
|
| 52 |
+
);
|
| 53 |
+
DialogHeader.displayName = "DialogHeader";
|
| 54 |
+
|
| 55 |
+
const DialogFooter = ({ className, ...props }) => (
|
| 56 |
+
<div
|
| 57 |
+
className={cn(
|
| 58 |
+
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
| 59 |
+
className
|
| 60 |
+
)}
|
| 61 |
+
{...props}
|
| 62 |
+
/>
|
| 63 |
+
);
|
| 64 |
+
DialogFooter.displayName = "DialogFooter";
|
| 65 |
+
|
| 66 |
+
const DialogTitle = React.forwardRef(({ className, ...props }, ref) => (
|
| 67 |
+
<DialogPrimitive.Title
|
| 68 |
+
ref={ref}
|
| 69 |
+
className={cn(
|
| 70 |
+
"text-lg font-semibold leading-none tracking-tight",
|
| 71 |
+
className
|
| 72 |
+
)}
|
| 73 |
+
{...props}
|
| 74 |
+
/>
|
| 75 |
+
));
|
| 76 |
+
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
| 77 |
+
|
| 78 |
+
const DialogDescription = React.forwardRef(({ className, ...props }, ref) => (
|
| 79 |
+
<DialogPrimitive.Description
|
| 80 |
+
ref={ref}
|
| 81 |
+
className={cn("text-sm text-muted-foreground", className)}
|
| 82 |
+
{...props}
|
| 83 |
+
/>
|
| 84 |
+
));
|
| 85 |
+
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
| 86 |
+
|
| 87 |
+
export {
|
| 88 |
+
Dialog,
|
| 89 |
+
DialogPortal,
|
| 90 |
+
DialogOverlay,
|
| 91 |
+
DialogClose,
|
| 92 |
+
DialogTrigger,
|
| 93 |
+
DialogContent,
|
| 94 |
+
DialogHeader,
|
| 95 |
+
DialogFooter,
|
| 96 |
+
DialogTitle,
|
| 97 |
+
DialogDescription,
|
| 98 |
+
};
|
| 99 |
+
|
frontend/src/components/ui/dropdown-menu.jsx
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react";
|
| 2 |
+
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
| 3 |
+
import { Check, ChevronRight, Circle } from "lucide-react";
|
| 4 |
+
import { cn } from "@/utils";
|
| 5 |
+
|
| 6 |
+
const DropdownMenu = DropdownMenuPrimitive.Root;
|
| 7 |
+
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
| 8 |
+
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
| 9 |
+
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
|
| 10 |
+
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
| 11 |
+
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
| 12 |
+
|
| 13 |
+
const DropdownMenuSubTrigger = React.forwardRef(({ className, inset, children, ...props }, ref) => (
|
| 14 |
+
<DropdownMenuPrimitive.SubTrigger
|
| 15 |
+
ref={ref}
|
| 16 |
+
className={cn(
|
| 17 |
+
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
|
| 18 |
+
inset && "pl-8",
|
| 19 |
+
className
|
| 20 |
+
)}
|
| 21 |
+
{...props}
|
| 22 |
+
>
|
| 23 |
+
{children}
|
| 24 |
+
<ChevronRight className="ml-auto h-4 w-4" />
|
| 25 |
+
</DropdownMenuPrimitive.SubTrigger>
|
| 26 |
+
));
|
| 27 |
+
DropdownMenuSubTrigger.displayName =
|
| 28 |
+
DropdownMenuPrimitive.SubTrigger.displayName;
|
| 29 |
+
|
| 30 |
+
const DropdownMenuSubContent = React.forwardRef(({ className, ...props }, ref) => (
|
| 31 |
+
<DropdownMenuPrimitive.SubContent
|
| 32 |
+
ref={ref}
|
| 33 |
+
className={cn(
|
| 34 |
+
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
| 35 |
+
className
|
| 36 |
+
)}
|
| 37 |
+
{...props}
|
| 38 |
+
/>
|
| 39 |
+
));
|
| 40 |
+
DropdownMenuSubContent.displayName =
|
| 41 |
+
DropdownMenuPrimitive.SubContent.displayName;
|
| 42 |
+
|
| 43 |
+
const DropdownMenuContent = React.forwardRef(({ className, sideOffset = 4, ...props }, ref) => (
|
| 44 |
+
<DropdownMenuPrimitive.Portal>
|
| 45 |
+
<DropdownMenuPrimitive.Content
|
| 46 |
+
ref={ref}
|
| 47 |
+
sideOffset={sideOffset}
|
| 48 |
+
className={cn(
|
| 49 |
+
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
| 50 |
+
className
|
| 51 |
+
)}
|
| 52 |
+
{...props}
|
| 53 |
+
/>
|
| 54 |
+
</DropdownMenuPrimitive.Portal>
|
| 55 |
+
));
|
| 56 |
+
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
| 57 |
+
|
| 58 |
+
const DropdownMenuItem = React.forwardRef(({ className, inset, ...props }, ref) => (
|
| 59 |
+
<DropdownMenuPrimitive.Item
|
| 60 |
+
ref={ref}
|
| 61 |
+
className={cn(
|
| 62 |
+
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
| 63 |
+
inset && "pl-8",
|
| 64 |
+
className
|
| 65 |
+
)}
|
| 66 |
+
{...props}
|
| 67 |
+
/>
|
| 68 |
+
));
|
| 69 |
+
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
| 70 |
+
|
| 71 |
+
const DropdownMenuCheckboxItem = React.forwardRef(({ className, children, checked, ...props }, ref) => (
|
| 72 |
+
<DropdownMenuPrimitive.CheckboxItem
|
| 73 |
+
ref={ref}
|
| 74 |
+
className={cn(
|
| 75 |
+
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
| 76 |
+
className
|
| 77 |
+
)}
|
| 78 |
+
checked={checked}
|
| 79 |
+
{...props}
|
| 80 |
+
>
|
| 81 |
+
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
| 82 |
+
<DropdownMenuPrimitive.ItemIndicator>
|
| 83 |
+
<Check className="h-4 w-4" />
|
| 84 |
+
</DropdownMenuPrimitive.ItemIndicator>
|
| 85 |
+
</span>
|
| 86 |
+
{children}
|
| 87 |
+
</DropdownMenuPrimitive.CheckboxItem>
|
| 88 |
+
));
|
| 89 |
+
DropdownMenuCheckboxItem.displayName =
|
| 90 |
+
DropdownMenuPrimitive.CheckboxItem.displayName;
|
| 91 |
+
|
| 92 |
+
const DropdownMenuRadioItem = React.forwardRef(({ className, children, ...props }, ref) => (
|
| 93 |
+
<DropdownMenuPrimitive.RadioItem
|
| 94 |
+
ref={ref}
|
| 95 |
+
className={cn(
|
| 96 |
+
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
| 97 |
+
className
|
| 98 |
+
)}
|
| 99 |
+
{...props}
|
| 100 |
+
>
|
| 101 |
+
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
| 102 |
+
<DropdownMenuPrimitive.ItemIndicator>
|
| 103 |
+
<Circle className="h-2 w-2 fill-current" />
|
| 104 |
+
</DropdownMenuPrimitive.ItemIndicator>
|
| 105 |
+
</span>
|
| 106 |
+
{children}
|
| 107 |
+
</DropdownMenuPrimitive.RadioItem>
|
| 108 |
+
));
|
| 109 |
+
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
|
| 110 |
+
|
| 111 |
+
const DropdownMenuLabel = React.forwardRef(({ className, inset, ...props }, ref) => (
|
| 112 |
+
<DropdownMenuPrimitive.Label
|
| 113 |
+
ref={ref}
|
| 114 |
+
className={cn(
|
| 115 |
+
"px-2 py-1.5 text-sm font-semibold",
|
| 116 |
+
inset && "pl-8",
|
| 117 |
+
className
|
| 118 |
+
)}
|
| 119 |
+
{...props}
|
| 120 |
+
/>
|
| 121 |
+
));
|
| 122 |
+
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
|
| 123 |
+
|
| 124 |
+
const DropdownMenuSeparator = React.forwardRef(({ className, ...props }, ref) => (
|
| 125 |
+
<DropdownMenuPrimitive.Separator
|
| 126 |
+
ref={ref}
|
| 127 |
+
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
| 128 |
+
{...props}
|
| 129 |
+
/>
|
| 130 |
+
));
|
| 131 |
+
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
| 132 |
+
|
| 133 |
+
const DropdownMenuShortcut = ({ className, ...props }) => {
|
| 134 |
+
return (
|
| 135 |
+
<span
|
| 136 |
+
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
| 137 |
+
{...props}
|
| 138 |
+
/>
|
| 139 |
+
);
|
| 140 |
+
};
|
| 141 |
+
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
|
| 142 |
+
|
| 143 |
+
export {
|
| 144 |
+
DropdownMenu,
|
| 145 |
+
DropdownMenuTrigger,
|
| 146 |
+
DropdownMenuContent,
|
| 147 |
+
DropdownMenuItem,
|
| 148 |
+
DropdownMenuCheckboxItem,
|
| 149 |
+
DropdownMenuRadioItem,
|
| 150 |
+
DropdownMenuLabel,
|
| 151 |
+
DropdownMenuSeparator,
|
| 152 |
+
DropdownMenuShortcut,
|
| 153 |
+
DropdownMenuGroup,
|
| 154 |
+
DropdownMenuPortal,
|
| 155 |
+
DropdownMenuSub,
|
| 156 |
+
DropdownMenuSubContent,
|
| 157 |
+
DropdownMenuSubTrigger,
|
| 158 |
+
DropdownMenuRadioGroup,
|
| 159 |
+
};
|
| 160 |
+
|
frontend/src/components/ui/input.jsx
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react";
|
| 2 |
+
import { cn } from "@/utils";
|
| 3 |
+
|
| 4 |
+
const Input = React.forwardRef(({ className, type, ...props }, ref) => {
|
| 5 |
+
return (
|
| 6 |
+
<input
|
| 7 |
+
type={type}
|
| 8 |
+
className={cn(
|
| 9 |
+
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
| 10 |
+
className
|
| 11 |
+
)}
|
| 12 |
+
ref={ref}
|
| 13 |
+
{...props}
|
| 14 |
+
/>
|
| 15 |
+
);
|
| 16 |
+
});
|
| 17 |
+
Input.displayName = "Input";
|
| 18 |
+
|
| 19 |
+
export { Input };
|
| 20 |
+
|
frontend/src/components/ui/label.jsx
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react";
|
| 2 |
+
import * as LabelPrimitive from "@radix-ui/react-label";
|
| 3 |
+
import { cn } from "@/utils";
|
| 4 |
+
|
| 5 |
+
const Label = React.forwardRef(({ className, ...props }, ref) => (
|
| 6 |
+
<LabelPrimitive.Root
|
| 7 |
+
ref={ref}
|
| 8 |
+
className={cn(
|
| 9 |
+
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
| 10 |
+
className
|
| 11 |
+
)}
|
| 12 |
+
{...props}
|
| 13 |
+
/>
|
| 14 |
+
));
|
| 15 |
+
Label.displayName = LabelPrimitive.Root.displayName;
|
| 16 |
+
|
| 17 |
+
export { Label };
|
| 18 |
+
|
frontend/src/components/ui/popover.jsx
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react";
|
| 2 |
+
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
| 3 |
+
import { cn } from "@/utils";
|
| 4 |
+
|
| 5 |
+
const Popover = PopoverPrimitive.Root;
|
| 6 |
+
const PopoverTrigger = PopoverPrimitive.Trigger;
|
| 7 |
+
const PopoverContent = React.forwardRef(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
| 8 |
+
<PopoverPrimitive.Portal>
|
| 9 |
+
<PopoverPrimitive.Content
|
| 10 |
+
ref={ref}
|
| 11 |
+
align={align}
|
| 12 |
+
sideOffset={sideOffset}
|
| 13 |
+
className={cn(
|
| 14 |
+
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
| 15 |
+
className
|
| 16 |
+
)}
|
| 17 |
+
{...props}
|
| 18 |
+
/>
|
| 19 |
+
</PopoverPrimitive.Portal>
|
| 20 |
+
));
|
| 21 |
+
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
|
| 22 |
+
|
| 23 |
+
export { Popover, PopoverTrigger, PopoverContent };
|
| 24 |
+
|
frontend/src/components/ui/progress.jsx
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react";
|
| 2 |
+
import * as ProgressPrimitive from "@radix-ui/react-progress";
|
| 3 |
+
import { cn } from "@/utils";
|
| 4 |
+
|
| 5 |
+
const Progress = React.forwardRef(({ className, value, ...props }, ref) => (
|
| 6 |
+
<ProgressPrimitive.Root
|
| 7 |
+
ref={ref}
|
| 8 |
+
className={cn(
|
| 9 |
+
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
|
| 10 |
+
className
|
| 11 |
+
)}
|
| 12 |
+
{...props}
|
| 13 |
+
>
|
| 14 |
+
<ProgressPrimitive.Indicator
|
| 15 |
+
className="h-full w-full flex-1 bg-primary transition-all"
|
| 16 |
+
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
| 17 |
+
/>
|
| 18 |
+
</ProgressPrimitive.Root>
|
| 19 |
+
));
|
| 20 |
+
Progress.displayName = ProgressPrimitive.Root.displayName;
|
| 21 |
+
|
| 22 |
+
export { Progress };
|
| 23 |
+
|
frontend/src/components/ui/select.jsx
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react";
|
| 2 |
+
import * as SelectPrimitive from "@radix-ui/react-select";
|
| 3 |
+
import { Check, ChevronDown, ChevronUp } from "lucide-react";
|
| 4 |
+
import { cn } from "@/utils";
|
| 5 |
+
|
| 6 |
+
const Select = SelectPrimitive.Root;
|
| 7 |
+
const SelectGroup = SelectPrimitive.Group;
|
| 8 |
+
const SelectValue = SelectPrimitive.Value;
|
| 9 |
+
|
| 10 |
+
const SelectTrigger = React.forwardRef(({ className, children, ...props }, ref) => (
|
| 11 |
+
<SelectPrimitive.Trigger
|
| 12 |
+
ref={ref}
|
| 13 |
+
className={cn(
|
| 14 |
+
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
| 15 |
+
className
|
| 16 |
+
)}
|
| 17 |
+
{...props}
|
| 18 |
+
>
|
| 19 |
+
{children}
|
| 20 |
+
<SelectPrimitive.Icon asChild>
|
| 21 |
+
<ChevronDown className="h-4 w-4 opacity-50" />
|
| 22 |
+
</SelectPrimitive.Icon>
|
| 23 |
+
</SelectPrimitive.Trigger>
|
| 24 |
+
));
|
| 25 |
+
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
| 26 |
+
|
| 27 |
+
const SelectScrollUpButton = React.forwardRef(({ className, ...props }, ref) => (
|
| 28 |
+
<SelectPrimitive.ScrollUpButton
|
| 29 |
+
ref={ref}
|
| 30 |
+
className={cn(
|
| 31 |
+
"flex cursor-default items-center justify-center py-1",
|
| 32 |
+
className
|
| 33 |
+
)}
|
| 34 |
+
{...props}
|
| 35 |
+
>
|
| 36 |
+
<ChevronUp className="h-4 w-4" />
|
| 37 |
+
</SelectPrimitive.ScrollUpButton>
|
| 38 |
+
));
|
| 39 |
+
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
| 40 |
+
|
| 41 |
+
const SelectScrollDownButton = React.forwardRef(({ className, ...props }, ref) => (
|
| 42 |
+
<SelectPrimitive.ScrollDownButton
|
| 43 |
+
ref={ref}
|
| 44 |
+
className={cn(
|
| 45 |
+
"flex cursor-default items-center justify-center py-1",
|
| 46 |
+
className
|
| 47 |
+
)}
|
| 48 |
+
{...props}
|
| 49 |
+
>
|
| 50 |
+
<ChevronDown className="h-4 w-4" />
|
| 51 |
+
</SelectPrimitive.ScrollDownButton>
|
| 52 |
+
));
|
| 53 |
+
SelectScrollDownButton.displayName =
|
| 54 |
+
SelectPrimitive.ScrollDownButton.displayName;
|
| 55 |
+
|
| 56 |
+
const SelectContent = React.forwardRef(({ className, children, position = "popper", ...props }, ref) => (
|
| 57 |
+
<SelectPrimitive.Portal>
|
| 58 |
+
<SelectPrimitive.Content
|
| 59 |
+
ref={ref}
|
| 60 |
+
className={cn(
|
| 61 |
+
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
| 62 |
+
position === "popper" &&
|
| 63 |
+
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
| 64 |
+
className
|
| 65 |
+
)}
|
| 66 |
+
position={position}
|
| 67 |
+
{...props}
|
| 68 |
+
>
|
| 69 |
+
<SelectScrollUpButton />
|
| 70 |
+
<SelectPrimitive.Viewport
|
| 71 |
+
className={cn(
|
| 72 |
+
"p-1",
|
| 73 |
+
position === "popper" &&
|
| 74 |
+
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
| 75 |
+
)}
|
| 76 |
+
>
|
| 77 |
+
{children}
|
| 78 |
+
</SelectPrimitive.Viewport>
|
| 79 |
+
<SelectScrollDownButton />
|
| 80 |
+
</SelectPrimitive.Content>
|
| 81 |
+
</SelectPrimitive.Portal>
|
| 82 |
+
));
|
| 83 |
+
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
| 84 |
+
|
| 85 |
+
const SelectLabel = React.forwardRef(({ className, ...props }, ref) => (
|
| 86 |
+
<SelectPrimitive.Label
|
| 87 |
+
ref={ref}
|
| 88 |
+
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
| 89 |
+
{...props}
|
| 90 |
+
/>
|
| 91 |
+
));
|
| 92 |
+
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
| 93 |
+
|
| 94 |
+
const SelectItem = React.forwardRef(({ className, children, ...props }, ref) => (
|
| 95 |
+
<SelectPrimitive.Item
|
| 96 |
+
ref={ref}
|
| 97 |
+
className={cn(
|
| 98 |
+
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
| 99 |
+
className
|
| 100 |
+
)}
|
| 101 |
+
{...props}
|
| 102 |
+
>
|
| 103 |
+
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
| 104 |
+
<SelectPrimitive.ItemIndicator>
|
| 105 |
+
<Check className="h-4 w-4" />
|
| 106 |
+
</SelectPrimitive.ItemIndicator>
|
| 107 |
+
</span>
|
| 108 |
+
|
| 109 |
+
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
| 110 |
+
</SelectPrimitive.Item>
|
| 111 |
+
));
|
| 112 |
+
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
| 113 |
+
|
| 114 |
+
const SelectSeparator = React.forwardRef(({ className, ...props }, ref) => (
|
| 115 |
+
<SelectPrimitive.Separator
|
| 116 |
+
ref={ref}
|
| 117 |
+
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
| 118 |
+
{...props}
|
| 119 |
+
/>
|
| 120 |
+
));
|
| 121 |
+
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
| 122 |
+
|
| 123 |
+
export {
|
| 124 |
+
Select,
|
| 125 |
+
SelectGroup,
|
| 126 |
+
SelectValue,
|
| 127 |
+
SelectTrigger,
|
| 128 |
+
SelectContent,
|
| 129 |
+
SelectLabel,
|
| 130 |
+
SelectItem,
|
| 131 |
+
SelectSeparator,
|
| 132 |
+
SelectScrollUpButton,
|
| 133 |
+
SelectScrollDownButton,
|
| 134 |
+
};
|
| 135 |
+
|
frontend/src/components/ui/separator.jsx
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react";
|
| 2 |
+
import * as SeparatorPrimitive from "@radix-ui/react-separator";
|
| 3 |
+
import { cn } from "@/utils";
|
| 4 |
+
|
| 5 |
+
const Separator = React.forwardRef(
|
| 6 |
+
(
|
| 7 |
+
{ className, orientation = "horizontal", decorative = true, ...props },
|
| 8 |
+
ref
|
| 9 |
+
) => (
|
| 10 |
+
<SeparatorPrimitive.Root
|
| 11 |
+
ref={ref}
|
| 12 |
+
decorative={decorative}
|
| 13 |
+
orientation={orientation}
|
| 14 |
+
className={cn(
|
| 15 |
+
"shrink-0 bg-border",
|
| 16 |
+
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
| 17 |
+
className
|
| 18 |
+
)}
|
| 19 |
+
{...props}
|
| 20 |
+
/>
|
| 21 |
+
)
|
| 22 |
+
);
|
| 23 |
+
Separator.displayName = SeparatorPrimitive.Root.displayName;
|
| 24 |
+
|
| 25 |
+
export { Separator };
|
| 26 |
+
|
frontend/src/components/ui/slider.jsx
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react";
|
| 2 |
+
import * as SliderPrimitive from "@radix-ui/react-slider";
|
| 3 |
+
import { cn } from "@/utils";
|
| 4 |
+
|
| 5 |
+
const Slider = React.forwardRef(({ className, ...props }, ref) => (
|
| 6 |
+
<SliderPrimitive.Root
|
| 7 |
+
ref={ref}
|
| 8 |
+
className={cn(
|
| 9 |
+
"relative flex w-full touch-none select-none items-center",
|
| 10 |
+
className
|
| 11 |
+
)}
|
| 12 |
+
{...props}
|
| 13 |
+
>
|
| 14 |
+
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
|
| 15 |
+
<SliderPrimitive.Range className="absolute h-full bg-primary" />
|
| 16 |
+
</SliderPrimitive.Track>
|
| 17 |
+
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
|
| 18 |
+
</SliderPrimitive.Root>
|
| 19 |
+
));
|
| 20 |
+
Slider.displayName = SliderPrimitive.Root.displayName;
|
| 21 |
+
|
| 22 |
+
export { Slider };
|
| 23 |
+
|
frontend/src/components/ui/switch.jsx
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react";
|
| 2 |
+
import * as SwitchPrimitives from "@radix-ui/react-switch";
|
| 3 |
+
import { cn } from "@/utils";
|
| 4 |
+
|
| 5 |
+
const Switch = React.forwardRef(({ className, ...props }, ref) => (
|
| 6 |
+
<SwitchPrimitives.Root
|
| 7 |
+
className={cn(
|
| 8 |
+
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
| 9 |
+
className
|
| 10 |
+
)}
|
| 11 |
+
{...props}
|
| 12 |
+
ref={ref}
|
| 13 |
+
>
|
| 14 |
+
<SwitchPrimitives.Thumb
|
| 15 |
+
className={cn(
|
| 16 |
+
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
|
| 17 |
+
)}
|
| 18 |
+
/>
|
| 19 |
+
</SwitchPrimitives.Root>
|
| 20 |
+
));
|
| 21 |
+
Switch.displayName = SwitchPrimitives.Root.displayName;
|
| 22 |
+
|
| 23 |
+
export { Switch };
|
| 24 |
+
|
frontend/src/components/ui/tabs.jsx
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react";
|
| 2 |
+
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
| 3 |
+
import { cn } from "@/utils";
|
| 4 |
+
|
| 5 |
+
const Tabs = TabsPrimitive.Root;
|
| 6 |
+
const TabsList = React.forwardRef(({ className, ...props }, ref) => (
|
| 7 |
+
<TabsPrimitive.List
|
| 8 |
+
ref={ref}
|
| 9 |
+
className={cn(
|
| 10 |
+
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
| 11 |
+
className
|
| 12 |
+
)}
|
| 13 |
+
{...props}
|
| 14 |
+
/>
|
| 15 |
+
));
|
| 16 |
+
TabsList.displayName = TabsPrimitive.List.displayName;
|
| 17 |
+
|
| 18 |
+
const TabsTrigger = React.forwardRef(({ className, ...props }, ref) => (
|
| 19 |
+
<TabsPrimitive.Trigger
|
| 20 |
+
ref={ref}
|
| 21 |
+
className={cn(
|
| 22 |
+
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
| 23 |
+
className
|
| 24 |
+
)}
|
| 25 |
+
{...props}
|
| 26 |
+
/>
|
| 27 |
+
));
|
| 28 |
+
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
| 29 |
+
|
| 30 |
+
const TabsContent = React.forwardRef(({ className, ...props }, ref) => (
|
| 31 |
+
<TabsPrimitive.Content
|
| 32 |
+
ref={ref}
|
| 33 |
+
className={cn(
|
| 34 |
+
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
| 35 |
+
className
|
| 36 |
+
)}
|
| 37 |
+
{...props}
|
| 38 |
+
/>
|
| 39 |
+
));
|
| 40 |
+
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
| 41 |
+
|
| 42 |
+
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
| 43 |
+
|
frontend/src/components/ui/textarea.jsx
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react";
|
| 2 |
+
import { cn } from "@/utils";
|
| 3 |
+
|
| 4 |
+
const Textarea = React.forwardRef(({ className, ...props }, ref) => {
|
| 5 |
+
return (
|
| 6 |
+
<textarea
|
| 7 |
+
className={cn(
|
| 8 |
+
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
| 9 |
+
className
|
| 10 |
+
)}
|
| 11 |
+
ref={ref}
|
| 12 |
+
{...props}
|
| 13 |
+
/>
|
| 14 |
+
);
|
| 15 |
+
});
|
| 16 |
+
Textarea.displayName = "Textarea";
|
| 17 |
+
|
| 18 |
+
export { Textarea };
|
| 19 |
+
|
frontend/src/index.css
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@tailwind base;
|
| 2 |
+
@tailwind components;
|
| 3 |
+
@tailwind utilities;
|
| 4 |
+
|
| 5 |
+
@layer base {
|
| 6 |
+
:root {
|
| 7 |
+
--background: 0 0% 100%;
|
| 8 |
+
--foreground: 222.2 84% 4.9%;
|
| 9 |
+
--card: 0 0% 100%;
|
| 10 |
+
--card-foreground: 222.2 84% 4.9%;
|
| 11 |
+
--popover: 0 0% 100%;
|
| 12 |
+
--popover-foreground: 222.2 84% 4.9%;
|
| 13 |
+
--primary: 221.2 83.2% 53.3%;
|
| 14 |
+
--primary-foreground: 210 40% 98%;
|
| 15 |
+
--secondary: 210 40% 96.1%;
|
| 16 |
+
--secondary-foreground: 222.2 47.4% 11.2%;
|
| 17 |
+
--muted: 210 40% 96.1%;
|
| 18 |
+
--muted-foreground: 215.4 16.3% 46.9%;
|
| 19 |
+
--accent: 210 40% 96.1%;
|
| 20 |
+
--accent-foreground: 222.2 47.4% 11.2%;
|
| 21 |
+
--destructive: 0 84.2% 60.2%;
|
| 22 |
+
--destructive-foreground: 210 40% 98%;
|
| 23 |
+
--border: 214.3 31.8% 91.4%;
|
| 24 |
+
--input: 214.3 31.8% 91.4%;
|
| 25 |
+
--ring: 221.2 83.2% 53.3%;
|
| 26 |
+
--radius: 0.5rem;
|
| 27 |
+
}
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
@layer base {
|
| 31 |
+
* {
|
| 32 |
+
@apply border-border;
|
| 33 |
+
}
|
| 34 |
+
body {
|
| 35 |
+
@apply bg-background text-foreground;
|
| 36 |
+
}
|
| 37 |
+
}
|
| 38 |
+
|
frontend/src/main.jsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
import React from "react";
|
| 2 |
import ReactDOM from "react-dom/client";
|
| 3 |
import App from "./App.jsx";
|
|
|
|
| 4 |
|
| 5 |
ReactDOM.createRoot(document.getElementById("root")).render(
|
| 6 |
<React.StrictMode>
|
|
|
|
| 1 |
import React from "react";
|
| 2 |
import ReactDOM from "react-dom/client";
|
| 3 |
import App from "./App.jsx";
|
| 4 |
+
import "./index.css";
|
| 5 |
|
| 6 |
ReactDOM.createRoot(document.getElementById("root")).render(
|
| 7 |
<React.StrictMode>
|
frontend/src/pages/Dashboard.jsx
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState } from 'react';
|
| 2 |
+
import { Link } from 'react-router-dom';
|
| 3 |
+
import { createPageUrl } from '@/utils';
|
| 4 |
+
import { motion } from 'framer-motion';
|
| 5 |
+
import {
|
| 6 |
+
Calendar,
|
| 7 |
+
FileText,
|
| 8 |
+
Image,
|
| 9 |
+
TrendingUp,
|
| 10 |
+
Clock,
|
| 11 |
+
Zap,
|
| 12 |
+
ArrowRight,
|
| 13 |
+
Plus,
|
| 14 |
+
Sparkles,
|
| 15 |
+
BarChart3,
|
| 16 |
+
FolderOpen,
|
| 17 |
+
Layers
|
| 18 |
+
} from 'lucide-react';
|
| 19 |
+
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
| 20 |
+
import { Button } from '@/components/ui/button';
|
| 21 |
+
import { Badge } from '@/components/ui/badge';
|
| 22 |
+
import { Progress } from '@/components/ui/progress';
|
| 23 |
+
|
| 24 |
+
export default function Dashboard() {
|
| 25 |
+
const stats = [
|
| 26 |
+
{ label: 'Scheduled Posts', value: '24', icon: Calendar, color: 'from-blue-500 to-indigo-600', change: '+12%' },
|
| 27 |
+
{ label: 'Repository Assets', value: '156', icon: FolderOpen, color: 'from-emerald-500 to-teal-600', change: '+8%' },
|
| 28 |
+
{ label: 'Posts This Month', value: '18', icon: TrendingUp, color: 'from-violet-500 to-purple-600', change: '+24%' },
|
| 29 |
+
{ label: 'Engagement Rate', value: '4.8%', icon: BarChart3, color: 'from-amber-500 to-orange-600', change: '+2.1%' },
|
| 30 |
+
];
|
| 31 |
+
|
| 32 |
+
const upcomingPosts = [
|
| 33 |
+
{ id: 1, title: 'OCR Document Automation Benefits', type: 'Carousel', date: 'Today, 2:00 PM', product: 'OCR', status: 'ready' },
|
| 34 |
+
{ id: 2, title: 'P2P Workflow Efficiency Guide', type: 'Cover Image + Content', date: 'Tomorrow, 10:00 AM', product: 'P2P', status: 'pending' },
|
| 35 |
+
{ id: 3, title: 'O2C Webinar Announcement', type: 'Webinar Invite', date: 'Dec 28, 3:00 PM', product: 'O2C', status: 'draft' },
|
| 36 |
+
];
|
| 37 |
+
|
| 38 |
+
const productStats = [
|
| 39 |
+
{ name: 'Document Parsing (OCR)', posts: 8, assets: 45, progress: 75 },
|
| 40 |
+
{ name: 'Purchase To Pay (P2P)', posts: 6, assets: 52, progress: 60 },
|
| 41 |
+
{ name: 'Order to Cash (O2C)', posts: 10, assets: 59, progress: 85 },
|
| 42 |
+
];
|
| 43 |
+
|
| 44 |
+
const container = {
|
| 45 |
+
hidden: { opacity: 0 },
|
| 46 |
+
show: {
|
| 47 |
+
opacity: 1,
|
| 48 |
+
transition: { staggerChildren: 0.1 }
|
| 49 |
+
}
|
| 50 |
+
};
|
| 51 |
+
|
| 52 |
+
const item = {
|
| 53 |
+
hidden: { opacity: 0, y: 20 },
|
| 54 |
+
show: { opacity: 1, y: 0 }
|
| 55 |
+
};
|
| 56 |
+
|
| 57 |
+
return (
|
| 58 |
+
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-blue-50/30">
|
| 59 |
+
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
| 60 |
+
{/* Header */}
|
| 61 |
+
<motion.div
|
| 62 |
+
initial={{ opacity: 0, y: -20 }}
|
| 63 |
+
animate={{ opacity: 1, y: 0 }}
|
| 64 |
+
className="mb-8"
|
| 65 |
+
>
|
| 66 |
+
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
| 67 |
+
<div>
|
| 68 |
+
<h1 className="text-3xl font-bold text-slate-900 tracking-tight">
|
| 69 |
+
Welcome back
|
| 70 |
+
</h1>
|
| 71 |
+
<p className="text-slate-500 mt-1">
|
| 72 |
+
Here's what's happening with your LinkedIn content
|
| 73 |
+
</p>
|
| 74 |
+
</div>
|
| 75 |
+
<div className="flex gap-3">
|
| 76 |
+
<Link to={createPageUrl('Scheduler')}>
|
| 77 |
+
<Button variant="outline" className="gap-2 border-slate-200 hover:bg-slate-50">
|
| 78 |
+
<Calendar className="w-4 h-4" />
|
| 79 |
+
Schedule
|
| 80 |
+
</Button>
|
| 81 |
+
</Link>
|
| 82 |
+
<Link to={createPageUrl('PostEditor')}>
|
| 83 |
+
<Button className="gap-2 bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700 shadow-lg shadow-blue-500/25">
|
| 84 |
+
<Plus className="w-4 h-4" />
|
| 85 |
+
Create Post
|
| 86 |
+
</Button>
|
| 87 |
+
</Link>
|
| 88 |
+
</div>
|
| 89 |
+
</div>
|
| 90 |
+
</motion.div>
|
| 91 |
+
|
| 92 |
+
{/* Stats Grid */}
|
| 93 |
+
<motion.div
|
| 94 |
+
variants={container}
|
| 95 |
+
initial="hidden"
|
| 96 |
+
animate="show"
|
| 97 |
+
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-8"
|
| 98 |
+
>
|
| 99 |
+
{stats.map((stat, index) => (
|
| 100 |
+
<motion.div key={index} variants={item}>
|
| 101 |
+
<Card className="border-0 shadow-lg shadow-slate-200/50 hover:shadow-xl transition-all duration-300 overflow-hidden group">
|
| 102 |
+
<CardContent className="p-6 relative">
|
| 103 |
+
<div className={`absolute top-0 right-0 w-32 h-32 bg-gradient-to-br ${stat.color} opacity-5 rounded-full -translate-y-1/2 translate-x-1/2 group-hover:scale-150 transition-transform duration-500`} />
|
| 104 |
+
<div className="flex items-start justify-between relative">
|
| 105 |
+
<div>
|
| 106 |
+
<p className="text-sm font-medium text-slate-500">{stat.label}</p>
|
| 107 |
+
<p className="text-3xl font-bold text-slate-900 mt-2">{stat.value}</p>
|
| 108 |
+
<Badge variant="secondary" className="mt-2 bg-emerald-50 text-emerald-700 border-0">
|
| 109 |
+
{stat.change}
|
| 110 |
+
</Badge>
|
| 111 |
+
</div>
|
| 112 |
+
<div className={`p-3 rounded-xl bg-gradient-to-br ${stat.color} shadow-lg`}>
|
| 113 |
+
<stat.icon className="w-5 h-5 text-white" />
|
| 114 |
+
</div>
|
| 115 |
+
</div>
|
| 116 |
+
</CardContent>
|
| 117 |
+
</Card>
|
| 118 |
+
</motion.div>
|
| 119 |
+
))}
|
| 120 |
+
</motion.div>
|
| 121 |
+
|
| 122 |
+
<div className="grid lg:grid-cols-3 gap-6">
|
| 123 |
+
{/* Upcoming Posts */}
|
| 124 |
+
<motion.div
|
| 125 |
+
initial={{ opacity: 0, x: -20 }}
|
| 126 |
+
animate={{ opacity: 1, x: 0 }}
|
| 127 |
+
transition={{ delay: 0.3 }}
|
| 128 |
+
className="lg:col-span-2"
|
| 129 |
+
>
|
| 130 |
+
<Card className="border-0 shadow-lg shadow-slate-200/50">
|
| 131 |
+
<CardHeader className="pb-4">
|
| 132 |
+
<div className="flex items-center justify-between">
|
| 133 |
+
<CardTitle className="text-lg font-semibold flex items-center gap-2">
|
| 134 |
+
<Clock className="w-5 h-5 text-blue-600" />
|
| 135 |
+
Upcoming Posts
|
| 136 |
+
</CardTitle>
|
| 137 |
+
<Link to={createPageUrl('Scheduler')}>
|
| 138 |
+
<Button variant="ghost" size="sm" className="text-blue-600 hover:text-blue-700 gap-1">
|
| 139 |
+
View all <ArrowRight className="w-4 h-4" />
|
| 140 |
+
</Button>
|
| 141 |
+
</Link>
|
| 142 |
+
</div>
|
| 143 |
+
</CardHeader>
|
| 144 |
+
<CardContent className="space-y-3">
|
| 145 |
+
{upcomingPosts.map((post, index) => (
|
| 146 |
+
<motion.div
|
| 147 |
+
key={post.id}
|
| 148 |
+
initial={{ opacity: 0, y: 10 }}
|
| 149 |
+
animate={{ opacity: 1, y: 0 }}
|
| 150 |
+
transition={{ delay: 0.4 + index * 0.1 }}
|
| 151 |
+
className="group p-4 rounded-xl bg-slate-50/50 hover:bg-white hover:shadow-md border border-transparent hover:border-slate-100 transition-all duration-300 cursor-pointer"
|
| 152 |
+
>
|
| 153 |
+
<div className="flex items-center gap-4">
|
| 154 |
+
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${
|
| 155 |
+
post.product === 'OCR' ? 'bg-blue-100 text-blue-600' :
|
| 156 |
+
post.product === 'P2P' ? 'bg-emerald-100 text-emerald-600' :
|
| 157 |
+
'bg-violet-100 text-violet-600'
|
| 158 |
+
}`}>
|
| 159 |
+
{post.type === 'Carousel' ? <Layers className="w-5 h-5" /> :
|
| 160 |
+
post.type === 'Webinar Invite' ? <Sparkles className="w-5 h-5" /> :
|
| 161 |
+
<Image className="w-5 h-5" />}
|
| 162 |
+
</div>
|
| 163 |
+
<div className="flex-1 min-w-0">
|
| 164 |
+
<h3 className="font-medium text-slate-900 truncate">{post.title}</h3>
|
| 165 |
+
<div className="flex items-center gap-2 mt-1">
|
| 166 |
+
<span className="text-sm text-slate-500">{post.date}</span>
|
| 167 |
+
<span className="text-slate-300">•</span>
|
| 168 |
+
<Badge variant="outline" className="text-xs border-slate-200">
|
| 169 |
+
{post.type}
|
| 170 |
+
</Badge>
|
| 171 |
+
</div>
|
| 172 |
+
</div>
|
| 173 |
+
<Badge className={`capitalize ${
|
| 174 |
+
post.status === 'ready' ? 'bg-emerald-100 text-emerald-700 border-0' :
|
| 175 |
+
post.status === 'pending' ? 'bg-amber-100 text-amber-700 border-0' :
|
| 176 |
+
'bg-slate-100 text-slate-600 border-0'
|
| 177 |
+
}`}>
|
| 178 |
+
{post.status}
|
| 179 |
+
</Badge>
|
| 180 |
+
</div>
|
| 181 |
+
</motion.div>
|
| 182 |
+
))}
|
| 183 |
+
</CardContent>
|
| 184 |
+
</Card>
|
| 185 |
+
</motion.div>
|
| 186 |
+
|
| 187 |
+
{/* Product Distribution */}
|
| 188 |
+
<motion.div
|
| 189 |
+
initial={{ opacity: 0, x: 20 }}
|
| 190 |
+
animate={{ opacity: 1, x: 0 }}
|
| 191 |
+
transition={{ delay: 0.4 }}
|
| 192 |
+
>
|
| 193 |
+
<Card className="border-0 shadow-lg shadow-slate-200/50 h-full">
|
| 194 |
+
<CardHeader className="pb-4">
|
| 195 |
+
<CardTitle className="text-lg font-semibold flex items-center gap-2">
|
| 196 |
+
<Zap className="w-5 h-5 text-amber-500" />
|
| 197 |
+
Content by Product
|
| 198 |
+
</CardTitle>
|
| 199 |
+
</CardHeader>
|
| 200 |
+
<CardContent className="space-y-5">
|
| 201 |
+
{productStats.map((product, index) => (
|
| 202 |
+
<div key={index} className="space-y-2">
|
| 203 |
+
<div className="flex items-center justify-between">
|
| 204 |
+
<span className="text-sm font-medium text-slate-700">{product.name}</span>
|
| 205 |
+
<span className="text-xs text-slate-500">{product.posts} posts</span>
|
| 206 |
+
</div>
|
| 207 |
+
<Progress value={product.progress} className="h-2" />
|
| 208 |
+
<div className="flex items-center justify-between text-xs text-slate-500">
|
| 209 |
+
<span>{product.assets} assets</span>
|
| 210 |
+
<span>{product.progress}% coverage</span>
|
| 211 |
+
</div>
|
| 212 |
+
</div>
|
| 213 |
+
))}
|
| 214 |
+
</CardContent>
|
| 215 |
+
</Card>
|
| 216 |
+
</motion.div>
|
| 217 |
+
</div>
|
| 218 |
+
|
| 219 |
+
{/* Quick Actions */}
|
| 220 |
+
<motion.div
|
| 221 |
+
initial={{ opacity: 0, y: 20 }}
|
| 222 |
+
animate={{ opacity: 1, y: 0 }}
|
| 223 |
+
transition={{ delay: 0.5 }}
|
| 224 |
+
className="mt-8"
|
| 225 |
+
>
|
| 226 |
+
<h2 className="text-lg font-semibold text-slate-900 mb-4">Quick Actions</h2>
|
| 227 |
+
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
| 228 |
+
{[
|
| 229 |
+
{ label: 'Upload Assets', icon: FolderOpen, color: 'blue', href: 'Repository' },
|
| 230 |
+
{ label: 'Schedule Campaign', icon: Calendar, color: 'violet', href: 'Scheduler' },
|
| 231 |
+
{ label: 'Connect Canva', icon: Sparkles, color: 'pink', href: 'Integrations' },
|
| 232 |
+
{ label: 'View Analytics', icon: BarChart3, color: 'emerald', href: 'Dashboard' },
|
| 233 |
+
].map((action, index) => (
|
| 234 |
+
<Link key={index} to={createPageUrl(action.href)}>
|
| 235 |
+
<Card className="border-0 shadow-md hover:shadow-lg transition-all duration-300 cursor-pointer group overflow-hidden">
|
| 236 |
+
<CardContent className="p-5 flex items-center gap-4">
|
| 237 |
+
<div className={`p-3 rounded-xl bg-${action.color}-100 group-hover:scale-110 transition-transform`}>
|
| 238 |
+
<action.icon className={`w-5 h-5 text-${action.color}-600`} />
|
| 239 |
+
</div>
|
| 240 |
+
<span className="font-medium text-slate-700 group-hover:text-slate-900">{action.label}</span>
|
| 241 |
+
<ArrowRight className="w-4 h-4 text-slate-400 ml-auto group-hover:translate-x-1 transition-transform" />
|
| 242 |
+
</CardContent>
|
| 243 |
+
</Card>
|
| 244 |
+
</Link>
|
| 245 |
+
))}
|
| 246 |
+
</div>
|
| 247 |
+
</motion.div>
|
| 248 |
+
</div>
|
| 249 |
+
</div>
|
| 250 |
+
);
|
| 251 |
+
}
|
| 252 |
+
|
frontend/src/pages/Integrations.jsx
ADDED
|
@@ -0,0 +1,420 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState } from 'react';
|
| 2 |
+
import { motion } from 'framer-motion';
|
| 3 |
+
import {
|
| 4 |
+
Link2,
|
| 5 |
+
Check,
|
| 6 |
+
X,
|
| 7 |
+
ExternalLink,
|
| 8 |
+
Settings,
|
| 9 |
+
RefreshCw,
|
| 10 |
+
Shield,
|
| 11 |
+
Key,
|
| 12 |
+
AlertCircle,
|
| 13 |
+
CheckCircle,
|
| 14 |
+
Linkedin,
|
| 15 |
+
Palette,
|
| 16 |
+
Zap,
|
| 17 |
+
ArrowRight,
|
| 18 |
+
Info,
|
| 19 |
+
Save
|
| 20 |
+
} from 'lucide-react';
|
| 21 |
+
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
| 22 |
+
import { Button } from '@/components/ui/button';
|
| 23 |
+
import { Badge } from '@/components/ui/badge';
|
| 24 |
+
import { Switch } from '@/components/ui/switch';
|
| 25 |
+
import { Label } from '@/components/ui/label';
|
| 26 |
+
import { Input } from '@/components/ui/input';
|
| 27 |
+
import { Separator } from '@/components/ui/separator';
|
| 28 |
+
import {
|
| 29 |
+
Dialog,
|
| 30 |
+
DialogContent,
|
| 31 |
+
DialogHeader,
|
| 32 |
+
DialogTitle,
|
| 33 |
+
DialogTrigger,
|
| 34 |
+
DialogDescription,
|
| 35 |
+
} from '@/components/ui/dialog';
|
| 36 |
+
import {
|
| 37 |
+
Alert,
|
| 38 |
+
AlertDescription,
|
| 39 |
+
AlertTitle,
|
| 40 |
+
} from '@/components/ui/alert';
|
| 41 |
+
|
| 42 |
+
const integrations = [
|
| 43 |
+
{
|
| 44 |
+
id: 'linkedin',
|
| 45 |
+
name: 'LinkedIn',
|
| 46 |
+
description: 'Connect your LinkedIn account to schedule and publish posts directly',
|
| 47 |
+
icon: Linkedin,
|
| 48 |
+
color: 'blue',
|
| 49 |
+
bgColor: 'bg-[#0A66C2]',
|
| 50 |
+
connected: true,
|
| 51 |
+
account: 'alex.business@company.com',
|
| 52 |
+
lastSync: '2 mins ago',
|
| 53 |
+
features: [
|
| 54 |
+
'Schedule posts up to 60 days in advance',
|
| 55 |
+
'Publish carousel, image, and text posts',
|
| 56 |
+
'Track post performance analytics',
|
| 57 |
+
'Manage multiple company pages'
|
| 58 |
+
]
|
| 59 |
+
},
|
| 60 |
+
{
|
| 61 |
+
id: 'canva',
|
| 62 |
+
name: 'Canva',
|
| 63 |
+
description: 'Import designs directly from your Canva workspace for post visuals',
|
| 64 |
+
icon: Palette,
|
| 65 |
+
color: 'purple',
|
| 66 |
+
bgColor: 'bg-gradient-to-br from-[#00C4CC] to-[#7B2FF7]',
|
| 67 |
+
connected: false,
|
| 68 |
+
account: null,
|
| 69 |
+
lastSync: null,
|
| 70 |
+
features: [
|
| 71 |
+
'Browse and import Canva designs',
|
| 72 |
+
'Access brand kit assets',
|
| 73 |
+
'Import templates for carousels',
|
| 74 |
+
'Sync design updates automatically'
|
| 75 |
+
]
|
| 76 |
+
},
|
| 77 |
+
];
|
| 78 |
+
|
| 79 |
+
export default function Integrations() {
|
| 80 |
+
const [linkedInDialogOpen, setLinkedInDialogOpen] = useState(false);
|
| 81 |
+
const [canvaDialogOpen, setCanvaDialogOpen] = useState(false);
|
| 82 |
+
const [autoPost, setAutoPost] = useState(true);
|
| 83 |
+
const [notifications, setNotifications] = useState(true);
|
| 84 |
+
|
| 85 |
+
return (
|
| 86 |
+
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-blue-50/30">
|
| 87 |
+
<div className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
| 88 |
+
{/* Header */}
|
| 89 |
+
<motion.div
|
| 90 |
+
initial={{ opacity: 0, y: -20 }}
|
| 91 |
+
animate={{ opacity: 1, y: 0 }}
|
| 92 |
+
className="mb-8"
|
| 93 |
+
>
|
| 94 |
+
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
| 95 |
+
<div>
|
| 96 |
+
<h1 className="text-3xl font-bold text-slate-900 tracking-tight">
|
| 97 |
+
Integrations
|
| 98 |
+
</h1>
|
| 99 |
+
<p className="text-slate-500 mt-1">
|
| 100 |
+
Connect your tools to streamline your content workflow
|
| 101 |
+
</p>
|
| 102 |
+
</div>
|
| 103 |
+
</div>
|
| 104 |
+
</motion.div>
|
| 105 |
+
|
| 106 |
+
{/* Integration Status Banner */}
|
| 107 |
+
<motion.div
|
| 108 |
+
initial={{ opacity: 0, y: 10 }}
|
| 109 |
+
animate={{ opacity: 1, y: 0 }}
|
| 110 |
+
className="mb-8"
|
| 111 |
+
>
|
| 112 |
+
<Alert className="border-blue-200 bg-blue-50">
|
| 113 |
+
<Info className="h-4 w-4 text-blue-600" />
|
| 114 |
+
<AlertTitle className="text-blue-800">Quick Setup</AlertTitle>
|
| 115 |
+
<AlertDescription className="text-blue-700">
|
| 116 |
+
Connect both LinkedIn and Canva to unlock the full potential of automated content scheduling. Your designs will flow seamlessly to your LinkedIn audience.
|
| 117 |
+
</AlertDescription>
|
| 118 |
+
</Alert>
|
| 119 |
+
</motion.div>
|
| 120 |
+
|
| 121 |
+
{/* Integration Cards */}
|
| 122 |
+
<div className="space-y-6">
|
| 123 |
+
{integrations.map((integration, index) => (
|
| 124 |
+
<motion.div
|
| 125 |
+
key={integration.id}
|
| 126 |
+
initial={{ opacity: 0, y: 20 }}
|
| 127 |
+
animate={{ opacity: 1, y: 0 }}
|
| 128 |
+
transition={{ delay: index * 0.1 }}
|
| 129 |
+
>
|
| 130 |
+
<Card className="border-0 shadow-lg shadow-slate-200/50 overflow-hidden">
|
| 131 |
+
<CardContent className="p-0">
|
| 132 |
+
<div className="flex flex-col lg:flex-row">
|
| 133 |
+
{/* Left Section - Integration Info */}
|
| 134 |
+
<div className="flex-1 p-6">
|
| 135 |
+
<div className="flex items-start gap-4">
|
| 136 |
+
<div className={`w-14 h-14 rounded-xl ${integration.bgColor} flex items-center justify-center shadow-lg`}>
|
| 137 |
+
<integration.icon className="w-7 h-7 text-white" />
|
| 138 |
+
</div>
|
| 139 |
+
<div className="flex-1">
|
| 140 |
+
<div className="flex items-center gap-3">
|
| 141 |
+
<h3 className="text-xl font-semibold text-slate-900">
|
| 142 |
+
{integration.name}
|
| 143 |
+
</h3>
|
| 144 |
+
{integration.connected ? (
|
| 145 |
+
<Badge className="bg-emerald-100 text-emerald-700 border-0 gap-1">
|
| 146 |
+
<CheckCircle className="w-3 h-3" />
|
| 147 |
+
Connected
|
| 148 |
+
</Badge>
|
| 149 |
+
) : (
|
| 150 |
+
<Badge variant="outline" className="text-slate-500 border-slate-300">
|
| 151 |
+
Not Connected
|
| 152 |
+
</Badge>
|
| 153 |
+
)}
|
| 154 |
+
</div>
|
| 155 |
+
<p className="text-slate-500 mt-1">
|
| 156 |
+
{integration.description}
|
| 157 |
+
</p>
|
| 158 |
+
|
| 159 |
+
{integration.connected && (
|
| 160 |
+
<div className="flex items-center gap-4 mt-3">
|
| 161 |
+
<div className="flex items-center gap-2 text-sm">
|
| 162 |
+
<Key className="w-4 h-4 text-slate-400" />
|
| 163 |
+
<span className="text-slate-600">{integration.account}</span>
|
| 164 |
+
</div>
|
| 165 |
+
<div className="flex items-center gap-2 text-sm">
|
| 166 |
+
<RefreshCw className="w-4 h-4 text-slate-400" />
|
| 167 |
+
<span className="text-slate-500">Synced {integration.lastSync}</span>
|
| 168 |
+
</div>
|
| 169 |
+
</div>
|
| 170 |
+
)}
|
| 171 |
+
</div>
|
| 172 |
+
</div>
|
| 173 |
+
|
| 174 |
+
{/* Features */}
|
| 175 |
+
<div className="mt-6">
|
| 176 |
+
<p className="text-sm font-medium text-slate-700 mb-3">Features</p>
|
| 177 |
+
<div className="grid sm:grid-cols-2 gap-2">
|
| 178 |
+
{integration.features.map((feature, idx) => (
|
| 179 |
+
<div key={idx} className="flex items-center gap-2 text-sm text-slate-600">
|
| 180 |
+
<Check className="w-4 h-4 text-emerald-500 shrink-0" />
|
| 181 |
+
<span>{feature}</span>
|
| 182 |
+
</div>
|
| 183 |
+
))}
|
| 184 |
+
</div>
|
| 185 |
+
</div>
|
| 186 |
+
</div>
|
| 187 |
+
|
| 188 |
+
{/* Right Section - Actions */}
|
| 189 |
+
<div className="lg:w-64 p-6 bg-slate-50 border-t lg:border-t-0 lg:border-l border-slate-100 flex flex-col justify-center">
|
| 190 |
+
{integration.connected ? (
|
| 191 |
+
<div className="space-y-4">
|
| 192 |
+
<Dialog>
|
| 193 |
+
<DialogTrigger asChild>
|
| 194 |
+
<Button variant="outline" className="w-full gap-2">
|
| 195 |
+
<Settings className="w-4 h-4" />
|
| 196 |
+
Settings
|
| 197 |
+
</Button>
|
| 198 |
+
</DialogTrigger>
|
| 199 |
+
<DialogContent>
|
| 200 |
+
<DialogHeader>
|
| 201 |
+
<DialogTitle className="flex items-center gap-3">
|
| 202 |
+
<div className={`w-10 h-10 rounded-lg ${integration.bgColor} flex items-center justify-center`}>
|
| 203 |
+
<integration.icon className="w-5 h-5 text-white" />
|
| 204 |
+
</div>
|
| 205 |
+
{integration.name} Settings
|
| 206 |
+
</DialogTitle>
|
| 207 |
+
<DialogDescription>
|
| 208 |
+
Configure your {integration.name} integration preferences
|
| 209 |
+
</DialogDescription>
|
| 210 |
+
</DialogHeader>
|
| 211 |
+
<div className="space-y-6 pt-4">
|
| 212 |
+
<div className="flex items-center justify-between">
|
| 213 |
+
<div>
|
| 214 |
+
<Label className="text-sm font-medium">Auto-post scheduled content</Label>
|
| 215 |
+
<p className="text-xs text-slate-500 mt-0.5">
|
| 216 |
+
Automatically publish posts at scheduled times
|
| 217 |
+
</p>
|
| 218 |
+
</div>
|
| 219 |
+
<Switch checked={autoPost} onCheckedChange={setAutoPost} />
|
| 220 |
+
</div>
|
| 221 |
+
<Separator />
|
| 222 |
+
<div className="flex items-center justify-between">
|
| 223 |
+
<div>
|
| 224 |
+
<Label className="text-sm font-medium">Post notifications</Label>
|
| 225 |
+
<p className="text-xs text-slate-500 mt-0.5">
|
| 226 |
+
Get notified when posts are published
|
| 227 |
+
</p>
|
| 228 |
+
</div>
|
| 229 |
+
<Switch checked={notifications} onCheckedChange={setNotifications} />
|
| 230 |
+
</div>
|
| 231 |
+
<Separator />
|
| 232 |
+
<div>
|
| 233 |
+
<Label className="text-sm font-medium">Connected Account</Label>
|
| 234 |
+
<div className="flex items-center gap-3 mt-2 p-3 rounded-lg bg-slate-50">
|
| 235 |
+
<div className={`w-8 h-8 rounded-lg ${integration.bgColor} flex items-center justify-center`}>
|
| 236 |
+
<integration.icon className="w-4 h-4 text-white" />
|
| 237 |
+
</div>
|
| 238 |
+
<div className="flex-1">
|
| 239 |
+
<p className="text-sm font-medium text-slate-700">{integration.account}</p>
|
| 240 |
+
<p className="text-xs text-slate-500">Connected</p>
|
| 241 |
+
</div>
|
| 242 |
+
<Button variant="ghost" size="sm" className="text-red-600 hover:text-red-700 hover:bg-red-50">
|
| 243 |
+
Disconnect
|
| 244 |
+
</Button>
|
| 245 |
+
</div>
|
| 246 |
+
</div>
|
| 247 |
+
</div>
|
| 248 |
+
</DialogContent>
|
| 249 |
+
</Dialog>
|
| 250 |
+
<Button variant="ghost" className="w-full gap-2 text-red-600 hover:text-red-700 hover:bg-red-50">
|
| 251 |
+
<X className="w-4 h-4" />
|
| 252 |
+
Disconnect
|
| 253 |
+
</Button>
|
| 254 |
+
</div>
|
| 255 |
+
) : (
|
| 256 |
+
<div className="space-y-4">
|
| 257 |
+
<Dialog>
|
| 258 |
+
<DialogTrigger asChild>
|
| 259 |
+
<Button className={`w-full gap-2 ${
|
| 260 |
+
integration.id === 'linkedin'
|
| 261 |
+
? 'bg-[#0A66C2] hover:bg-[#004182]'
|
| 262 |
+
: 'bg-gradient-to-r from-[#00C4CC] to-[#7B2FF7] hover:opacity-90'
|
| 263 |
+
}`}>
|
| 264 |
+
<Link2 className="w-4 h-4" />
|
| 265 |
+
Connect {integration.name}
|
| 266 |
+
</Button>
|
| 267 |
+
</DialogTrigger>
|
| 268 |
+
<DialogContent>
|
| 269 |
+
<DialogHeader>
|
| 270 |
+
<DialogTitle className="flex items-center gap-3">
|
| 271 |
+
<div className={`w-10 h-10 rounded-lg ${integration.bgColor} flex items-center justify-center`}>
|
| 272 |
+
<integration.icon className="w-5 h-5 text-white" />
|
| 273 |
+
</div>
|
| 274 |
+
Connect to {integration.name}
|
| 275 |
+
</DialogTitle>
|
| 276 |
+
<DialogDescription>
|
| 277 |
+
Authorize access to your {integration.name} account
|
| 278 |
+
</DialogDescription>
|
| 279 |
+
</DialogHeader>
|
| 280 |
+
<div className="space-y-6 pt-4">
|
| 281 |
+
<div className="p-4 rounded-xl bg-slate-50 border border-slate-100">
|
| 282 |
+
<div className="flex items-start gap-3">
|
| 283 |
+
<Shield className="w-5 h-5 text-blue-600 mt-0.5" />
|
| 284 |
+
<div>
|
| 285 |
+
<p className="text-sm font-medium text-slate-700">Secure Connection</p>
|
| 286 |
+
<p className="text-xs text-slate-500 mt-1">
|
| 287 |
+
Your credentials are encrypted and never stored. We only request necessary permissions.
|
| 288 |
+
</p>
|
| 289 |
+
</div>
|
| 290 |
+
</div>
|
| 291 |
+
</div>
|
| 292 |
+
|
| 293 |
+
<div>
|
| 294 |
+
<Label className="text-sm font-medium">Permissions Requested</Label>
|
| 295 |
+
<div className="mt-2 space-y-2">
|
| 296 |
+
{integration.id === 'linkedin' ? (
|
| 297 |
+
<>
|
| 298 |
+
<div className="flex items-center gap-2 text-sm text-slate-600">
|
| 299 |
+
<Check className="w-4 h-4 text-emerald-500" />
|
| 300 |
+
Read and write posts on your behalf
|
| 301 |
+
</div>
|
| 302 |
+
<div className="flex items-center gap-2 text-sm text-slate-600">
|
| 303 |
+
<Check className="w-4 h-4 text-emerald-500" />
|
| 304 |
+
Schedule posts for future publishing
|
| 305 |
+
</div>
|
| 306 |
+
<div className="flex items-center gap-2 text-sm text-slate-600">
|
| 307 |
+
<Check className="w-4 h-4 text-emerald-500" />
|
| 308 |
+
Access post analytics and insights
|
| 309 |
+
</div>
|
| 310 |
+
</>
|
| 311 |
+
) : (
|
| 312 |
+
<>
|
| 313 |
+
<div className="flex items-center gap-2 text-sm text-slate-600">
|
| 314 |
+
<Check className="w-4 h-4 text-emerald-500" />
|
| 315 |
+
View your Canva designs
|
| 316 |
+
</div>
|
| 317 |
+
<div className="flex items-center gap-2 text-sm text-slate-600">
|
| 318 |
+
<Check className="w-4 h-4 text-emerald-500" />
|
| 319 |
+
Download designs as images
|
| 320 |
+
</div>
|
| 321 |
+
<div className="flex items-center gap-2 text-sm text-slate-600">
|
| 322 |
+
<Check className="w-4 h-4 text-emerald-500" />
|
| 323 |
+
Access brand kit assets
|
| 324 |
+
</div>
|
| 325 |
+
</>
|
| 326 |
+
)}
|
| 327 |
+
</div>
|
| 328 |
+
</div>
|
| 329 |
+
|
| 330 |
+
<Button className={`w-full gap-2 ${
|
| 331 |
+
integration.id === 'linkedin'
|
| 332 |
+
? 'bg-[#0A66C2] hover:bg-[#004182]'
|
| 333 |
+
: 'bg-gradient-to-r from-[#00C4CC] to-[#7B2FF7] hover:opacity-90'
|
| 334 |
+
}`}>
|
| 335 |
+
Continue with {integration.name}
|
| 336 |
+
<ExternalLink className="w-4 h-4" />
|
| 337 |
+
</Button>
|
| 338 |
+
</div>
|
| 339 |
+
</DialogContent>
|
| 340 |
+
</Dialog>
|
| 341 |
+
<p className="text-xs text-center text-slate-500">
|
| 342 |
+
You'll be redirected to {integration.name} to authorize
|
| 343 |
+
</p>
|
| 344 |
+
</div>
|
| 345 |
+
)}
|
| 346 |
+
</div>
|
| 347 |
+
</div>
|
| 348 |
+
</CardContent>
|
| 349 |
+
</Card>
|
| 350 |
+
</motion.div>
|
| 351 |
+
))}
|
| 352 |
+
</div>
|
| 353 |
+
|
| 354 |
+
{/* API Configuration */}
|
| 355 |
+
<motion.div
|
| 356 |
+
initial={{ opacity: 0, y: 20 }}
|
| 357 |
+
animate={{ opacity: 1, y: 0 }}
|
| 358 |
+
transition={{ delay: 0.3 }}
|
| 359 |
+
className="mt-8"
|
| 360 |
+
>
|
| 361 |
+
<Card className="border-0 shadow-lg shadow-slate-200/50">
|
| 362 |
+
<CardHeader>
|
| 363 |
+
<CardTitle className="text-lg font-semibold flex items-center gap-2">
|
| 364 |
+
<Key className="w-5 h-5 text-amber-500" />
|
| 365 |
+
API Configuration
|
| 366 |
+
</CardTitle>
|
| 367 |
+
<CardDescription>
|
| 368 |
+
For advanced users who want to configure custom API settings
|
| 369 |
+
</CardDescription>
|
| 370 |
+
</CardHeader>
|
| 371 |
+
<CardContent className="space-y-4">
|
| 372 |
+
<div className="grid md:grid-cols-2 gap-4">
|
| 373 |
+
<div>
|
| 374 |
+
<Label className="text-sm text-slate-600">LinkedIn Client ID</Label>
|
| 375 |
+
<Input
|
| 376 |
+
className="mt-1.5"
|
| 377 |
+
placeholder="Enter your LinkedIn Client ID"
|
| 378 |
+
type="password"
|
| 379 |
+
defaultValue="••••••••••••••••"
|
| 380 |
+
/>
|
| 381 |
+
</div>
|
| 382 |
+
<div>
|
| 383 |
+
<Label className="text-sm text-slate-600">LinkedIn Client Secret</Label>
|
| 384 |
+
<Input
|
| 385 |
+
className="mt-1.5"
|
| 386 |
+
placeholder="Enter your LinkedIn Client Secret"
|
| 387 |
+
type="password"
|
| 388 |
+
defaultValue="••••••••••••••••"
|
| 389 |
+
/>
|
| 390 |
+
</div>
|
| 391 |
+
<div>
|
| 392 |
+
<Label className="text-sm text-slate-600">Canva API Key</Label>
|
| 393 |
+
<Input
|
| 394 |
+
className="mt-1.5"
|
| 395 |
+
placeholder="Enter your Canva API Key"
|
| 396 |
+
type="password"
|
| 397 |
+
/>
|
| 398 |
+
</div>
|
| 399 |
+
<div>
|
| 400 |
+
<Label className="text-sm text-slate-600">Webhook URL</Label>
|
| 401 |
+
<Input
|
| 402 |
+
className="mt-1.5"
|
| 403 |
+
placeholder="https://your-webhook-url.com"
|
| 404 |
+
/>
|
| 405 |
+
</div>
|
| 406 |
+
</div>
|
| 407 |
+
<div className="flex justify-end">
|
| 408 |
+
<Button variant="outline" className="gap-2">
|
| 409 |
+
<Save className="w-4 h-4" />
|
| 410 |
+
Save Configuration
|
| 411 |
+
</Button>
|
| 412 |
+
</div>
|
| 413 |
+
</CardContent>
|
| 414 |
+
</Card>
|
| 415 |
+
</motion.div>
|
| 416 |
+
</div>
|
| 417 |
+
</div>
|
| 418 |
+
);
|
| 419 |
+
}
|
| 420 |
+
|
frontend/src/pages/PostEditor.jsx
ADDED
|
@@ -0,0 +1,513 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState } from 'react';
|
| 2 |
+
import { motion } from 'framer-motion';
|
| 3 |
+
import {
|
| 4 |
+
Save,
|
| 5 |
+
Send,
|
| 6 |
+
Eye,
|
| 7 |
+
Calendar,
|
| 8 |
+
Clock,
|
| 9 |
+
Image,
|
| 10 |
+
Layers,
|
| 11 |
+
FileText,
|
| 12 |
+
Video,
|
| 13 |
+
Sparkles,
|
| 14 |
+
ChevronDown,
|
| 15 |
+
Plus,
|
| 16 |
+
X,
|
| 17 |
+
GripVertical,
|
| 18 |
+
RefreshCw,
|
| 19 |
+
ThumbsUp,
|
| 20 |
+
MessageCircle,
|
| 21 |
+
Share2,
|
| 22 |
+
MoreHorizontal,
|
| 23 |
+
Globe,
|
| 24 |
+
Link2,
|
| 25 |
+
Hash,
|
| 26 |
+
AtSign,
|
| 27 |
+
Bold,
|
| 28 |
+
Italic,
|
| 29 |
+
List,
|
| 30 |
+
Smile,
|
| 31 |
+
ImagePlus,
|
| 32 |
+
Trash2,
|
| 33 |
+
ArrowLeft,
|
| 34 |
+
Linkedin
|
| 35 |
+
} from 'lucide-react';
|
| 36 |
+
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
| 37 |
+
import { Button } from '@/components/ui/button';
|
| 38 |
+
import { Input } from '@/components/ui/input';
|
| 39 |
+
import { Textarea } from '@/components/ui/textarea';
|
| 40 |
+
import { Badge } from '@/components/ui/badge';
|
| 41 |
+
import { Label } from '@/components/ui/label';
|
| 42 |
+
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
| 43 |
+
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
| 44 |
+
import { Separator } from '@/components/ui/separator';
|
| 45 |
+
import {
|
| 46 |
+
Select,
|
| 47 |
+
SelectContent,
|
| 48 |
+
SelectItem,
|
| 49 |
+
SelectTrigger,
|
| 50 |
+
SelectValue,
|
| 51 |
+
} from '@/components/ui/select';
|
| 52 |
+
import {
|
| 53 |
+
Popover,
|
| 54 |
+
PopoverContent,
|
| 55 |
+
PopoverTrigger,
|
| 56 |
+
} from '@/components/ui/popover';
|
| 57 |
+
import { Calendar as CalendarComponent } from '@/components/ui/calendar';
|
| 58 |
+
import { format } from 'date-fns';
|
| 59 |
+
import { Link } from 'react-router-dom';
|
| 60 |
+
import { createPageUrl } from '@/utils';
|
| 61 |
+
|
| 62 |
+
const postTypes = [
|
| 63 |
+
{ id: 'carousel', name: 'Carousel', icon: Layers },
|
| 64 |
+
{ id: 'cover_content', name: 'Cover Image + Content', icon: Image },
|
| 65 |
+
{ id: 'content_only', name: 'Content Only', icon: FileText },
|
| 66 |
+
{ id: 'webinar', name: 'Webinar Invite', icon: Video },
|
| 67 |
+
];
|
| 68 |
+
|
| 69 |
+
const products = [
|
| 70 |
+
{ id: 'ocr', name: 'Document Parsing (OCR)', shortName: 'OCR' },
|
| 71 |
+
{ id: 'p2p', name: 'Purchase To Pay', shortName: 'P2P' },
|
| 72 |
+
{ id: 'o2c', name: 'Order to Cash', shortName: 'O2C' },
|
| 73 |
+
];
|
| 74 |
+
|
| 75 |
+
export default function PostEditor() {
|
| 76 |
+
const [postType, setPostType] = useState('carousel');
|
| 77 |
+
const [product, setProduct] = useState('ocr');
|
| 78 |
+
const [content, setContent] = useState(`🚀 Transform Your Document Processing with AI-Powered OCR
|
| 79 |
+
|
| 80 |
+
Are you still manually processing invoices and documents?
|
| 81 |
+
|
| 82 |
+
Here's how our Intelligent Document Parsing solution can revolutionize your workflow:
|
| 83 |
+
|
| 84 |
+
✅ 99.5% accuracy in data extraction
|
| 85 |
+
✅ Process 1000+ documents per hour
|
| 86 |
+
✅ Seamless integration with existing systems
|
| 87 |
+
✅ Reduce manual errors by 95%
|
| 88 |
+
|
| 89 |
+
The future of document automation is here. Ready to transform your business?
|
| 90 |
+
|
| 91 |
+
#DocumentAutomation #OCR #AITechnology #DigitalTransformation #BusinessEfficiency`);
|
| 92 |
+
const [scheduledDate, setScheduledDate] = useState(new Date());
|
| 93 |
+
const [scheduledTime, setScheduledTime] = useState('10:00');
|
| 94 |
+
const [carouselSlides, setCarouselSlides] = useState([
|
| 95 |
+
{ id: 1, title: 'Slide 1', hasImage: true },
|
| 96 |
+
{ id: 2, title: 'Slide 2', hasImage: true },
|
| 97 |
+
{ id: 3, title: 'Slide 3', hasImage: true },
|
| 98 |
+
]);
|
| 99 |
+
const [selectedAssets, setSelectedAssets] = useState([
|
| 100 |
+
{ id: 1, name: 'OCR_Demo.png', type: 'image' },
|
| 101 |
+
{ id: 2, name: 'Workflow_Diagram.png', type: 'image' },
|
| 102 |
+
]);
|
| 103 |
+
|
| 104 |
+
const addSlide = () => {
|
| 105 |
+
setCarouselSlides([...carouselSlides, { id: Date.now(), title: `Slide ${carouselSlides.length + 1}`, hasImage: false }]);
|
| 106 |
+
};
|
| 107 |
+
|
| 108 |
+
const removeSlide = (id) => {
|
| 109 |
+
setCarouselSlides(carouselSlides.filter(slide => slide.id !== id));
|
| 110 |
+
};
|
| 111 |
+
|
| 112 |
+
return (
|
| 113 |
+
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-blue-50/30">
|
| 114 |
+
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
| 115 |
+
{/* Header */}
|
| 116 |
+
<motion.div
|
| 117 |
+
initial={{ opacity: 0, y: -20 }}
|
| 118 |
+
animate={{ opacity: 1, y: 0 }}
|
| 119 |
+
className="mb-8"
|
| 120 |
+
>
|
| 121 |
+
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
| 122 |
+
<div className="flex items-center gap-4">
|
| 123 |
+
<Link to={createPageUrl('Scheduler')}>
|
| 124 |
+
<Button variant="ghost" size="icon" className="h-10 w-10">
|
| 125 |
+
<ArrowLeft className="w-5 h-5" />
|
| 126 |
+
</Button>
|
| 127 |
+
</Link>
|
| 128 |
+
<div>
|
| 129 |
+
<h1 className="text-2xl font-bold text-slate-900 tracking-tight">
|
| 130 |
+
Create Post
|
| 131 |
+
</h1>
|
| 132 |
+
<p className="text-slate-500 text-sm mt-0.5">
|
| 133 |
+
Compose and schedule your LinkedIn content
|
| 134 |
+
</p>
|
| 135 |
+
</div>
|
| 136 |
+
</div>
|
| 137 |
+
<div className="flex gap-3">
|
| 138 |
+
<Button variant="outline" className="gap-2 border-slate-200">
|
| 139 |
+
<Save className="w-4 h-4" />
|
| 140 |
+
Save Draft
|
| 141 |
+
</Button>
|
| 142 |
+
<Button variant="outline" className="gap-2 border-slate-200">
|
| 143 |
+
<Eye className="w-4 h-4" />
|
| 144 |
+
Preview
|
| 145 |
+
</Button>
|
| 146 |
+
<Button className="gap-2 bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700 shadow-lg shadow-blue-500/25">
|
| 147 |
+
<Send className="w-4 h-4" />
|
| 148 |
+
Schedule Post
|
| 149 |
+
</Button>
|
| 150 |
+
</div>
|
| 151 |
+
</div>
|
| 152 |
+
</motion.div>
|
| 153 |
+
|
| 154 |
+
<div className="grid lg:grid-cols-5 gap-6">
|
| 155 |
+
{/* Editor Panel */}
|
| 156 |
+
<motion.div
|
| 157 |
+
initial={{ opacity: 0, x: -20 }}
|
| 158 |
+
animate={{ opacity: 1, x: 0 }}
|
| 159 |
+
className="lg:col-span-3 space-y-6"
|
| 160 |
+
>
|
| 161 |
+
{/* Post Type Selection */}
|
| 162 |
+
<Card className="border-0 shadow-lg shadow-slate-200/50">
|
| 163 |
+
<CardHeader className="pb-4">
|
| 164 |
+
<CardTitle className="text-base font-semibold">Post Type</CardTitle>
|
| 165 |
+
</CardHeader>
|
| 166 |
+
<CardContent>
|
| 167 |
+
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
| 168 |
+
{postTypes.map(type => (
|
| 169 |
+
<button
|
| 170 |
+
key={type.id}
|
| 171 |
+
onClick={() => setPostType(type.id)}
|
| 172 |
+
className={`p-4 rounded-xl border-2 transition-all ${
|
| 173 |
+
postType === type.id
|
| 174 |
+
? 'border-blue-500 bg-blue-50'
|
| 175 |
+
: 'border-slate-200 hover:border-slate-300 bg-white'
|
| 176 |
+
}`}
|
| 177 |
+
>
|
| 178 |
+
<type.icon className={`w-6 h-6 mx-auto mb-2 ${
|
| 179 |
+
postType === type.id ? 'text-blue-600' : 'text-slate-400'
|
| 180 |
+
}`} />
|
| 181 |
+
<p className={`text-sm font-medium ${
|
| 182 |
+
postType === type.id ? 'text-blue-700' : 'text-slate-600'
|
| 183 |
+
}`}>
|
| 184 |
+
{type.name}
|
| 185 |
+
</p>
|
| 186 |
+
</button>
|
| 187 |
+
))}
|
| 188 |
+
</div>
|
| 189 |
+
</CardContent>
|
| 190 |
+
</Card>
|
| 191 |
+
|
| 192 |
+
{/* Content Editor */}
|
| 193 |
+
<Card className="border-0 shadow-lg shadow-slate-200/50">
|
| 194 |
+
<CardHeader className="pb-4">
|
| 195 |
+
<div className="flex items-center justify-between">
|
| 196 |
+
<CardTitle className="text-base font-semibold">Content</CardTitle>
|
| 197 |
+
<Button variant="outline" size="sm" className="gap-2 text-violet-600 border-violet-200 hover:bg-violet-50">
|
| 198 |
+
<Sparkles className="w-4 h-4" />
|
| 199 |
+
AI Generate
|
| 200 |
+
</Button>
|
| 201 |
+
</div>
|
| 202 |
+
</CardHeader>
|
| 203 |
+
<CardContent className="space-y-4">
|
| 204 |
+
{/* Formatting Toolbar */}
|
| 205 |
+
<div className="flex items-center gap-1 p-2 bg-slate-50 rounded-lg">
|
| 206 |
+
<Button variant="ghost" size="icon" className="h-8 w-8">
|
| 207 |
+
<Bold className="w-4 h-4" />
|
| 208 |
+
</Button>
|
| 209 |
+
<Button variant="ghost" size="icon" className="h-8 w-8">
|
| 210 |
+
<Italic className="w-4 h-4" />
|
| 211 |
+
</Button>
|
| 212 |
+
<Button variant="ghost" size="icon" className="h-8 w-8">
|
| 213 |
+
<List className="w-4 h-4" />
|
| 214 |
+
</Button>
|
| 215 |
+
<Separator orientation="vertical" className="h-6 mx-1" />
|
| 216 |
+
<Button variant="ghost" size="icon" className="h-8 w-8">
|
| 217 |
+
<Hash className="w-4 h-4" />
|
| 218 |
+
</Button>
|
| 219 |
+
<Button variant="ghost" size="icon" className="h-8 w-8">
|
| 220 |
+
<AtSign className="w-4 h-4" />
|
| 221 |
+
</Button>
|
| 222 |
+
<Button variant="ghost" size="icon" className="h-8 w-8">
|
| 223 |
+
<Link2 className="w-4 h-4" />
|
| 224 |
+
</Button>
|
| 225 |
+
<Button variant="ghost" size="icon" className="h-8 w-8">
|
| 226 |
+
<Smile className="w-4 h-4" />
|
| 227 |
+
</Button>
|
| 228 |
+
</div>
|
| 229 |
+
|
| 230 |
+
<Textarea
|
| 231 |
+
value={content}
|
| 232 |
+
onChange={(e) => setContent(e.target.value)}
|
| 233 |
+
className="min-h-[250px] text-sm leading-relaxed border-slate-200 focus:border-blue-300"
|
| 234 |
+
placeholder="Write your post content here..."
|
| 235 |
+
/>
|
| 236 |
+
|
| 237 |
+
<div className="flex items-center justify-between text-sm text-slate-500">
|
| 238 |
+
<span>{content.length} / 3000 characters</span>
|
| 239 |
+
<Button variant="ghost" size="sm" className="gap-1 text-blue-600">
|
| 240 |
+
<RefreshCw className="w-3 h-3" />
|
| 241 |
+
Regenerate
|
| 242 |
+
</Button>
|
| 243 |
+
</div>
|
| 244 |
+
</CardContent>
|
| 245 |
+
</Card>
|
| 246 |
+
|
| 247 |
+
{/* Carousel Slides (if carousel type) */}
|
| 248 |
+
{postType === 'carousel' && (
|
| 249 |
+
<Card className="border-0 shadow-lg shadow-slate-200/50">
|
| 250 |
+
<CardHeader className="pb-4">
|
| 251 |
+
<div className="flex items-center justify-between">
|
| 252 |
+
<CardTitle className="text-base font-semibold">Carousel Slides</CardTitle>
|
| 253 |
+
<Button onClick={addSlide} variant="outline" size="sm" className="gap-1">
|
| 254 |
+
<Plus className="w-4 h-4" />
|
| 255 |
+
Add Slide
|
| 256 |
+
</Button>
|
| 257 |
+
</div>
|
| 258 |
+
</CardHeader>
|
| 259 |
+
<CardContent>
|
| 260 |
+
<div className="space-y-3">
|
| 261 |
+
{carouselSlides.map((slide, index) => (
|
| 262 |
+
<motion.div
|
| 263 |
+
key={slide.id}
|
| 264 |
+
initial={{ opacity: 0, y: 10 }}
|
| 265 |
+
animate={{ opacity: 1, y: 0 }}
|
| 266 |
+
className="flex items-center gap-3 p-3 rounded-xl border border-slate-200 bg-white hover:border-slate-300 transition-colors"
|
| 267 |
+
>
|
| 268 |
+
<GripVertical className="w-4 h-4 text-slate-400 cursor-grab" />
|
| 269 |
+
<div className="w-16 h-16 rounded-lg bg-gradient-to-br from-slate-100 to-slate-50 flex items-center justify-center border border-slate-200">
|
| 270 |
+
{slide.hasImage ? (
|
| 271 |
+
<Image className="w-6 h-6 text-slate-400" />
|
| 272 |
+
) : (
|
| 273 |
+
<ImagePlus className="w-6 h-6 text-slate-300" />
|
| 274 |
+
)}
|
| 275 |
+
</div>
|
| 276 |
+
<div className="flex-1">
|
| 277 |
+
<Input
|
| 278 |
+
value={slide.title}
|
| 279 |
+
className="h-8 text-sm border-0 bg-transparent p-0 font-medium"
|
| 280 |
+
placeholder="Slide title"
|
| 281 |
+
/>
|
| 282 |
+
<p className="text-xs text-slate-500 mt-0.5">
|
| 283 |
+
{slide.hasImage ? 'Image attached' : 'No image'}
|
| 284 |
+
</p>
|
| 285 |
+
</div>
|
| 286 |
+
<Button variant="ghost" size="sm" className="gap-1 text-blue-600">
|
| 287 |
+
<ImagePlus className="w-4 h-4" />
|
| 288 |
+
{slide.hasImage ? 'Change' : 'Add'} Image
|
| 289 |
+
</Button>
|
| 290 |
+
<Button
|
| 291 |
+
variant="ghost"
|
| 292 |
+
size="icon"
|
| 293 |
+
onClick={() => removeSlide(slide.id)}
|
| 294 |
+
className="h-8 w-8 text-slate-400 hover:text-red-500"
|
| 295 |
+
>
|
| 296 |
+
<Trash2 className="w-4 h-4" />
|
| 297 |
+
</Button>
|
| 298 |
+
</motion.div>
|
| 299 |
+
))}
|
| 300 |
+
</div>
|
| 301 |
+
</CardContent>
|
| 302 |
+
</Card>
|
| 303 |
+
)}
|
| 304 |
+
|
| 305 |
+
{/* Media Attachments */}
|
| 306 |
+
<Card className="border-0 shadow-lg shadow-slate-200/50">
|
| 307 |
+
<CardHeader className="pb-4">
|
| 308 |
+
<div className="flex items-center justify-between">
|
| 309 |
+
<CardTitle className="text-base font-semibold">Media from Repository</CardTitle>
|
| 310 |
+
<Link to={createPageUrl('Repository')}>
|
| 311 |
+
<Button variant="outline" size="sm" className="gap-1">
|
| 312 |
+
<Plus className="w-4 h-4" />
|
| 313 |
+
Browse Assets
|
| 314 |
+
</Button>
|
| 315 |
+
</Link>
|
| 316 |
+
</div>
|
| 317 |
+
</CardHeader>
|
| 318 |
+
<CardContent>
|
| 319 |
+
<div className="flex flex-wrap gap-3">
|
| 320 |
+
{selectedAssets.map(asset => (
|
| 321 |
+
<div
|
| 322 |
+
key={asset.id}
|
| 323 |
+
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-slate-50 border border-slate-200"
|
| 324 |
+
>
|
| 325 |
+
<Image className="w-4 h-4 text-blue-500" />
|
| 326 |
+
<span className="text-sm text-slate-700">{asset.name}</span>
|
| 327 |
+
<Button variant="ghost" size="icon" className="h-5 w-5 text-slate-400 hover:text-red-500">
|
| 328 |
+
<X className="w-3 h-3" />
|
| 329 |
+
</Button>
|
| 330 |
+
</div>
|
| 331 |
+
))}
|
| 332 |
+
<button className="flex items-center gap-2 px-3 py-2 rounded-lg border-2 border-dashed border-slate-200 text-slate-500 hover:border-blue-300 hover:text-blue-600 transition-colors">
|
| 333 |
+
<Plus className="w-4 h-4" />
|
| 334 |
+
<span className="text-sm">Add from Canva</span>
|
| 335 |
+
</button>
|
| 336 |
+
</div>
|
| 337 |
+
</CardContent>
|
| 338 |
+
</Card>
|
| 339 |
+
</motion.div>
|
| 340 |
+
|
| 341 |
+
{/* Preview & Settings Panel */}
|
| 342 |
+
<motion.div
|
| 343 |
+
initial={{ opacity: 0, x: 20 }}
|
| 344 |
+
animate={{ opacity: 1, x: 0 }}
|
| 345 |
+
className="lg:col-span-2 space-y-6"
|
| 346 |
+
>
|
| 347 |
+
{/* Schedule Settings */}
|
| 348 |
+
<Card className="border-0 shadow-lg shadow-slate-200/50">
|
| 349 |
+
<CardHeader className="pb-4">
|
| 350 |
+
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
| 351 |
+
<Calendar className="w-4 h-4 text-blue-600" />
|
| 352 |
+
Schedule
|
| 353 |
+
</CardTitle>
|
| 354 |
+
</CardHeader>
|
| 355 |
+
<CardContent className="space-y-4">
|
| 356 |
+
<div>
|
| 357 |
+
<Label className="text-sm text-slate-600">Product Category</Label>
|
| 358 |
+
<Select value={product} onValueChange={setProduct}>
|
| 359 |
+
<SelectTrigger className="mt-1.5">
|
| 360 |
+
<SelectValue />
|
| 361 |
+
</SelectTrigger>
|
| 362 |
+
<SelectContent>
|
| 363 |
+
{products.map(p => (
|
| 364 |
+
<SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>
|
| 365 |
+
))}
|
| 366 |
+
</SelectContent>
|
| 367 |
+
</Select>
|
| 368 |
+
</div>
|
| 369 |
+
|
| 370 |
+
<div className="grid grid-cols-2 gap-3">
|
| 371 |
+
<div>
|
| 372 |
+
<Label className="text-sm text-slate-600">Date</Label>
|
| 373 |
+
<Popover>
|
| 374 |
+
<PopoverTrigger asChild>
|
| 375 |
+
<Button variant="outline" className="w-full justify-start mt-1.5 text-left font-normal">
|
| 376 |
+
<Calendar className="w-4 h-4 mr-2" />
|
| 377 |
+
{format(scheduledDate, 'MMM d, yyyy')}
|
| 378 |
+
</Button>
|
| 379 |
+
</PopoverTrigger>
|
| 380 |
+
<PopoverContent className="w-auto p-0" align="start">
|
| 381 |
+
<CalendarComponent
|
| 382 |
+
mode="single"
|
| 383 |
+
selected={scheduledDate}
|
| 384 |
+
onSelect={setScheduledDate}
|
| 385 |
+
/>
|
| 386 |
+
</PopoverContent>
|
| 387 |
+
</Popover>
|
| 388 |
+
</div>
|
| 389 |
+
<div>
|
| 390 |
+
<Label className="text-sm text-slate-600">Time</Label>
|
| 391 |
+
<Select value={scheduledTime} onValueChange={setScheduledTime}>
|
| 392 |
+
<SelectTrigger className="mt-1.5">
|
| 393 |
+
<Clock className="w-4 h-4 mr-2" />
|
| 394 |
+
<SelectValue />
|
| 395 |
+
</SelectTrigger>
|
| 396 |
+
<SelectContent>
|
| 397 |
+
{['09:00', '10:00', '11:00', '12:00', '13:00', '14:00', '15:00', '16:00', '17:00'].map(time => (
|
| 398 |
+
<SelectItem key={time} value={time}>{time}</SelectItem>
|
| 399 |
+
))}
|
| 400 |
+
</SelectContent>
|
| 401 |
+
</Select>
|
| 402 |
+
</div>
|
| 403 |
+
</div>
|
| 404 |
+
</CardContent>
|
| 405 |
+
</Card>
|
| 406 |
+
|
| 407 |
+
{/* LinkedIn Preview */}
|
| 408 |
+
<Card className="border-0 shadow-lg shadow-slate-200/50 overflow-hidden">
|
| 409 |
+
<CardHeader className="pb-3 bg-gradient-to-r from-[#0A66C2] to-[#004182]">
|
| 410 |
+
<CardTitle className="text-base font-semibold text-white flex items-center gap-2">
|
| 411 |
+
<Linkedin className="w-4 h-4" />
|
| 412 |
+
LinkedIn Preview
|
| 413 |
+
</CardTitle>
|
| 414 |
+
</CardHeader>
|
| 415 |
+
<CardContent className="p-0">
|
| 416 |
+
<div className="p-4 bg-white">
|
| 417 |
+
{/* Post Header */}
|
| 418 |
+
<div className="flex items-start gap-3">
|
| 419 |
+
<Avatar className="h-12 w-12">
|
| 420 |
+
<AvatarImage src="https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=100&h=100&fit=crop" />
|
| 421 |
+
<AvatarFallback>AB</AvatarFallback>
|
| 422 |
+
</Avatar>
|
| 423 |
+
<div className="flex-1">
|
| 424 |
+
<p className="font-semibold text-sm text-slate-900">Alex Business</p>
|
| 425 |
+
<p className="text-xs text-slate-500">Marketing Director at TechCorp</p>
|
| 426 |
+
<p className="text-xs text-slate-400 flex items-center gap-1 mt-0.5">
|
| 427 |
+
{format(scheduledDate, 'MMM d')} • <Globe className="w-3 h-3" />
|
| 428 |
+
</p>
|
| 429 |
+
</div>
|
| 430 |
+
<Button variant="ghost" size="icon" className="h-8 w-8">
|
| 431 |
+
<MoreHorizontal className="w-4 h-4" />
|
| 432 |
+
</Button>
|
| 433 |
+
</div>
|
| 434 |
+
|
| 435 |
+
{/* Post Content */}
|
| 436 |
+
<div className="mt-3 text-sm text-slate-800 whitespace-pre-line leading-relaxed">
|
| 437 |
+
{content.length > 300 ? content.slice(0, 300) + '...' : content}
|
| 438 |
+
{content.length > 300 && (
|
| 439 |
+
<button className="text-blue-600 hover:underline ml-1">see more</button>
|
| 440 |
+
)}
|
| 441 |
+
</div>
|
| 442 |
+
|
| 443 |
+
{/* Post Image Preview */}
|
| 444 |
+
{postType !== 'content_only' && (
|
| 445 |
+
<div className="mt-3 -mx-4">
|
| 446 |
+
<div className="aspect-video bg-gradient-to-br from-blue-100 to-indigo-100 flex items-center justify-center">
|
| 447 |
+
{postType === 'carousel' ? (
|
| 448 |
+
<div className="text-center">
|
| 449 |
+
<Layers className="w-12 h-12 text-blue-400 mx-auto mb-2" />
|
| 450 |
+
<p className="text-sm text-blue-600">{carouselSlides.length} slides</p>
|
| 451 |
+
</div>
|
| 452 |
+
) : (
|
| 453 |
+
<Image className="w-12 h-12 text-blue-400" />
|
| 454 |
+
)}
|
| 455 |
+
</div>
|
| 456 |
+
</div>
|
| 457 |
+
)}
|
| 458 |
+
|
| 459 |
+
{/* Engagement Stats */}
|
| 460 |
+
<div className="flex items-center justify-between mt-3 pt-3 border-t border-slate-100">
|
| 461 |
+
<div className="flex items-center gap-1 text-xs text-slate-500">
|
| 462 |
+
<div className="flex -space-x-1">
|
| 463 |
+
<div className="w-4 h-4 rounded-full bg-blue-500 flex items-center justify-center">
|
| 464 |
+
<ThumbsUp className="w-2 h-2 text-white" />
|
| 465 |
+
</div>
|
| 466 |
+
<div className="w-4 h-4 rounded-full bg-red-500 flex items-center justify-center">
|
| 467 |
+
<span className="text-[8px]">❤️</span>
|
| 468 |
+
</div>
|
| 469 |
+
</div>
|
| 470 |
+
<span>Preview mode</span>
|
| 471 |
+
</div>
|
| 472 |
+
<span className="text-xs text-slate-500">0 comments</span>
|
| 473 |
+
</div>
|
| 474 |
+
|
| 475 |
+
{/* Action Buttons */}
|
| 476 |
+
<div className="flex items-center justify-between mt-3 pt-3 border-t border-slate-100">
|
| 477 |
+
{[
|
| 478 |
+
{ icon: ThumbsUp, label: 'Like' },
|
| 479 |
+
{ icon: MessageCircle, label: 'Comment' },
|
| 480 |
+
{ icon: Share2, label: 'Share' },
|
| 481 |
+
{ icon: Send, label: 'Send' },
|
| 482 |
+
].map((action, idx) => (
|
| 483 |
+
<button
|
| 484 |
+
key={idx}
|
| 485 |
+
className="flex items-center gap-1.5 px-3 py-2 text-slate-600 hover:bg-slate-50 rounded-lg transition-colors"
|
| 486 |
+
>
|
| 487 |
+
<action.icon className="w-4 h-4" />
|
| 488 |
+
<span className="text-xs font-medium">{action.label}</span>
|
| 489 |
+
</button>
|
| 490 |
+
))}
|
| 491 |
+
</div>
|
| 492 |
+
</div>
|
| 493 |
+
</CardContent>
|
| 494 |
+
</Card>
|
| 495 |
+
|
| 496 |
+
{/* Action Buttons */}
|
| 497 |
+
<div className="space-y-3">
|
| 498 |
+
<Button className="w-full gap-2 h-12 bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700 shadow-lg shadow-blue-500/25">
|
| 499 |
+
<Send className="w-5 h-5" />
|
| 500 |
+
Schedule for {format(scheduledDate, 'MMM d')} at {scheduledTime}
|
| 501 |
+
</Button>
|
| 502 |
+
<Button variant="outline" className="w-full gap-2 h-12 border-slate-200">
|
| 503 |
+
<Eye className="w-5 h-5" />
|
| 504 |
+
Post Immediately
|
| 505 |
+
</Button>
|
| 506 |
+
</div>
|
| 507 |
+
</motion.div>
|
| 508 |
+
</div>
|
| 509 |
+
</div>
|
| 510 |
+
</div>
|
| 511 |
+
);
|
| 512 |
+
}
|
| 513 |
+
|
frontend/src/pages/Repository.jsx
ADDED
|
@@ -0,0 +1,510 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState } from 'react';
|
| 2 |
+
import { motion, AnimatePresence } from 'framer-motion';
|
| 3 |
+
import {
|
| 4 |
+
Upload,
|
| 5 |
+
FolderOpen,
|
| 6 |
+
Image,
|
| 7 |
+
FileText,
|
| 8 |
+
Video,
|
| 9 |
+
Search,
|
| 10 |
+
Filter,
|
| 11 |
+
Grid3X3,
|
| 12 |
+
List,
|
| 13 |
+
MoreVertical,
|
| 14 |
+
Download,
|
| 15 |
+
Trash2,
|
| 16 |
+
Eye,
|
| 17 |
+
ChevronDown,
|
| 18 |
+
ChevronRight,
|
| 19 |
+
Plus,
|
| 20 |
+
X,
|
| 21 |
+
Check,
|
| 22 |
+
File
|
| 23 |
+
} from 'lucide-react';
|
| 24 |
+
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
| 25 |
+
import { Button } from '@/components/ui/button';
|
| 26 |
+
import { Input } from '@/components/ui/input';
|
| 27 |
+
import { Badge } from '@/components/ui/badge';
|
| 28 |
+
import { Label } from '@/components/ui/label';
|
| 29 |
+
import {
|
| 30 |
+
Select,
|
| 31 |
+
SelectContent,
|
| 32 |
+
SelectItem,
|
| 33 |
+
SelectTrigger,
|
| 34 |
+
SelectValue,
|
| 35 |
+
} from '@/components/ui/select';
|
| 36 |
+
import {
|
| 37 |
+
DropdownMenu,
|
| 38 |
+
DropdownMenuContent,
|
| 39 |
+
DropdownMenuItem,
|
| 40 |
+
DropdownMenuTrigger,
|
| 41 |
+
} from '@/components/ui/dropdown-menu';
|
| 42 |
+
import {
|
| 43 |
+
Dialog,
|
| 44 |
+
DialogContent,
|
| 45 |
+
DialogHeader,
|
| 46 |
+
DialogTitle,
|
| 47 |
+
DialogTrigger,
|
| 48 |
+
} from '@/components/ui/dialog';
|
| 49 |
+
import { Checkbox } from '@/components/ui/checkbox';
|
| 50 |
+
|
| 51 |
+
const products = [
|
| 52 |
+
{
|
| 53 |
+
id: 'ocr',
|
| 54 |
+
name: 'Intelligent Document Parsing (OCR)',
|
| 55 |
+
shortName: 'OCR',
|
| 56 |
+
color: 'blue',
|
| 57 |
+
subCategories: []
|
| 58 |
+
},
|
| 59 |
+
{
|
| 60 |
+
id: 'p2p',
|
| 61 |
+
name: 'Purchase To Pay (P2P)',
|
| 62 |
+
shortName: 'P2P',
|
| 63 |
+
color: 'emerald',
|
| 64 |
+
subCategories: [
|
| 65 |
+
'Budget Approval Workflow',
|
| 66 |
+
'Purchase Request Workflow',
|
| 67 |
+
'Accounts Payable Workflow'
|
| 68 |
+
]
|
| 69 |
+
},
|
| 70 |
+
{
|
| 71 |
+
id: 'o2c',
|
| 72 |
+
name: 'Order to Cash (O2C)',
|
| 73 |
+
shortName: 'O2C',
|
| 74 |
+
color: 'violet',
|
| 75 |
+
subCategories: [
|
| 76 |
+
'Quotation Workflow',
|
| 77 |
+
'Sales Order Workflow',
|
| 78 |
+
'PickSlip Delivery Workflow',
|
| 79 |
+
'Accounts Receivable Workflow'
|
| 80 |
+
]
|
| 81 |
+
}
|
| 82 |
+
];
|
| 83 |
+
|
| 84 |
+
const mockAssets = [
|
| 85 |
+
{ id: 1, name: 'OCR_Demo_Screenshot.png', type: 'image', product: 'ocr', subCategory: null, size: '2.4 MB', date: '2024-12-20' },
|
| 86 |
+
{ id: 2, name: 'P2P_Workflow_Diagram.pdf', type: 'document', product: 'p2p', subCategory: 'Budget Approval Workflow', size: '1.8 MB', date: '2024-12-19' },
|
| 87 |
+
{ id: 3, name: 'Invoice_Processing_Video.mp4', type: 'video', product: 'ocr', subCategory: null, size: '45.2 MB', date: '2024-12-18' },
|
| 88 |
+
{ id: 4, name: 'Sales_Order_Infographic.png', type: 'image', product: 'o2c', subCategory: 'Sales Order Workflow', size: '3.1 MB', date: '2024-12-17' },
|
| 89 |
+
{ id: 5, name: 'AP_Automation_Brochure.pdf', type: 'document', product: 'p2p', subCategory: 'Accounts Payable Workflow', size: '5.6 MB', date: '2024-12-16' },
|
| 90 |
+
{ id: 6, name: 'O2C_Product_Banner.png', type: 'image', product: 'o2c', subCategory: 'Quotation Workflow', size: '1.2 MB', date: '2024-12-15' },
|
| 91 |
+
{ id: 7, name: 'Document_Parsing_Demo.png', type: 'image', product: 'ocr', subCategory: null, size: '890 KB', date: '2024-12-14' },
|
| 92 |
+
{ id: 8, name: 'PR_Workflow_Guide.pdf', type: 'document', product: 'p2p', subCategory: 'Purchase Request Workflow', size: '2.3 MB', date: '2024-12-13' },
|
| 93 |
+
];
|
| 94 |
+
|
| 95 |
+
export default function Repository() {
|
| 96 |
+
const [viewMode, setViewMode] = useState('grid');
|
| 97 |
+
const [searchQuery, setSearchQuery] = useState('');
|
| 98 |
+
const [selectedProduct, setSelectedProduct] = useState('all');
|
| 99 |
+
const [expandedProducts, setExpandedProducts] = useState(['ocr', 'p2p', 'o2c']);
|
| 100 |
+
const [selectedAssets, setSelectedAssets] = useState([]);
|
| 101 |
+
const [uploadDialogOpen, setUploadDialogOpen] = useState(false);
|
| 102 |
+
const [dragOver, setDragOver] = useState(false);
|
| 103 |
+
|
| 104 |
+
const toggleProduct = (productId) => {
|
| 105 |
+
setExpandedProducts(prev =>
|
| 106 |
+
prev.includes(productId)
|
| 107 |
+
? prev.filter(id => id !== productId)
|
| 108 |
+
: [...prev, productId]
|
| 109 |
+
);
|
| 110 |
+
};
|
| 111 |
+
|
| 112 |
+
const filteredAssets = mockAssets.filter(asset => {
|
| 113 |
+
const matchesSearch = asset.name.toLowerCase().includes(searchQuery.toLowerCase());
|
| 114 |
+
const matchesProduct = selectedProduct === 'all' || asset.product === selectedProduct;
|
| 115 |
+
return matchesSearch && matchesProduct;
|
| 116 |
+
});
|
| 117 |
+
|
| 118 |
+
const getAssetsByProduct = (productId) => {
|
| 119 |
+
return mockAssets.filter(asset => asset.product === productId);
|
| 120 |
+
};
|
| 121 |
+
|
| 122 |
+
const getTypeIcon = (type) => {
|
| 123 |
+
switch(type) {
|
| 124 |
+
case 'image': return <Image className="w-5 h-5 text-pink-500" />;
|
| 125 |
+
case 'video': return <Video className="w-5 h-5 text-red-500" />;
|
| 126 |
+
case 'document': return <FileText className="w-5 h-5 text-blue-500" />;
|
| 127 |
+
default: return <File className="w-5 h-5 text-slate-500" />;
|
| 128 |
+
}
|
| 129 |
+
};
|
| 130 |
+
|
| 131 |
+
const getProductColor = (productId) => {
|
| 132 |
+
const product = products.find(p => p.id === productId);
|
| 133 |
+
return product?.color || 'slate';
|
| 134 |
+
};
|
| 135 |
+
|
| 136 |
+
return (
|
| 137 |
+
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-blue-50/30">
|
| 138 |
+
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
| 139 |
+
{/* Header */}
|
| 140 |
+
<motion.div
|
| 141 |
+
initial={{ opacity: 0, y: -20 }}
|
| 142 |
+
animate={{ opacity: 1, y: 0 }}
|
| 143 |
+
className="mb-8"
|
| 144 |
+
>
|
| 145 |
+
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
| 146 |
+
<div>
|
| 147 |
+
<h1 className="text-3xl font-bold text-slate-900 tracking-tight">
|
| 148 |
+
Asset Repository
|
| 149 |
+
</h1>
|
| 150 |
+
<p className="text-slate-500 mt-1">
|
| 151 |
+
Manage your marketing materials, screenshots, and documents
|
| 152 |
+
</p>
|
| 153 |
+
</div>
|
| 154 |
+
<Dialog open={uploadDialogOpen} onOpenChange={setUploadDialogOpen}>
|
| 155 |
+
<DialogTrigger asChild>
|
| 156 |
+
<Button className="gap-2 bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700 shadow-lg shadow-blue-500/25">
|
| 157 |
+
<Upload className="w-4 h-4" />
|
| 158 |
+
Upload Assets
|
| 159 |
+
</Button>
|
| 160 |
+
</DialogTrigger>
|
| 161 |
+
<DialogContent className="sm:max-w-lg">
|
| 162 |
+
<DialogHeader>
|
| 163 |
+
<DialogTitle>Upload Assets</DialogTitle>
|
| 164 |
+
</DialogHeader>
|
| 165 |
+
<div className="space-y-4 pt-4">
|
| 166 |
+
<div
|
| 167 |
+
className={`border-2 border-dashed rounded-xl p-8 text-center transition-colors ${
|
| 168 |
+
dragOver ? 'border-blue-500 bg-blue-50' : 'border-slate-200 hover:border-slate-300'
|
| 169 |
+
}`}
|
| 170 |
+
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
|
| 171 |
+
onDragLeave={() => setDragOver(false)}
|
| 172 |
+
onDrop={(e) => { e.preventDefault(); setDragOver(false); }}
|
| 173 |
+
>
|
| 174 |
+
<Upload className="w-10 h-10 text-slate-400 mx-auto mb-3" />
|
| 175 |
+
<p className="text-sm font-medium text-slate-700">
|
| 176 |
+
Drag and drop files here
|
| 177 |
+
</p>
|
| 178 |
+
<p className="text-xs text-slate-500 mt-1">
|
| 179 |
+
or click to browse
|
| 180 |
+
</p>
|
| 181 |
+
<Button variant="outline" size="sm" className="mt-4">
|
| 182 |
+
Browse Files
|
| 183 |
+
</Button>
|
| 184 |
+
</div>
|
| 185 |
+
|
| 186 |
+
<div className="space-y-3">
|
| 187 |
+
<div>
|
| 188 |
+
<Label>Product Category</Label>
|
| 189 |
+
<Select>
|
| 190 |
+
<SelectTrigger className="mt-1.5">
|
| 191 |
+
<SelectValue placeholder="Select a product" />
|
| 192 |
+
</SelectTrigger>
|
| 193 |
+
<SelectContent>
|
| 194 |
+
{products.map(product => (
|
| 195 |
+
<SelectItem key={product.id} value={product.id}>
|
| 196 |
+
{product.name}
|
| 197 |
+
</SelectItem>
|
| 198 |
+
))}
|
| 199 |
+
</SelectContent>
|
| 200 |
+
</Select>
|
| 201 |
+
</div>
|
| 202 |
+
|
| 203 |
+
<div>
|
| 204 |
+
<Label>Sub-Category (Optional)</Label>
|
| 205 |
+
<Select>
|
| 206 |
+
<SelectTrigger className="mt-1.5">
|
| 207 |
+
<SelectValue placeholder="Select sub-category" />
|
| 208 |
+
</SelectTrigger>
|
| 209 |
+
<SelectContent>
|
| 210 |
+
<SelectItem value="none">None</SelectItem>
|
| 211 |
+
</SelectContent>
|
| 212 |
+
</Select>
|
| 213 |
+
</div>
|
| 214 |
+
</div>
|
| 215 |
+
|
| 216 |
+
<div className="flex justify-end gap-2 pt-4">
|
| 217 |
+
<Button variant="outline" onClick={() => setUploadDialogOpen(false)}>
|
| 218 |
+
Cancel
|
| 219 |
+
</Button>
|
| 220 |
+
<Button className="bg-blue-600 hover:bg-blue-700">
|
| 221 |
+
Upload
|
| 222 |
+
</Button>
|
| 223 |
+
</div>
|
| 224 |
+
</div>
|
| 225 |
+
</DialogContent>
|
| 226 |
+
</Dialog>
|
| 227 |
+
</div>
|
| 228 |
+
</motion.div>
|
| 229 |
+
|
| 230 |
+
<div className="grid lg:grid-cols-4 gap-6">
|
| 231 |
+
{/* Sidebar - Product Categories */}
|
| 232 |
+
<motion.div
|
| 233 |
+
initial={{ opacity: 0, x: -20 }}
|
| 234 |
+
animate={{ opacity: 1, x: 0 }}
|
| 235 |
+
className="lg:col-span-1"
|
| 236 |
+
>
|
| 237 |
+
<Card className="border-0 shadow-lg shadow-slate-200/50 sticky top-8">
|
| 238 |
+
<CardHeader className="pb-3">
|
| 239 |
+
<CardTitle className="text-sm font-semibold text-slate-600 uppercase tracking-wider">
|
| 240 |
+
Product Categories
|
| 241 |
+
</CardTitle>
|
| 242 |
+
</CardHeader>
|
| 243 |
+
<CardContent className="pt-0">
|
| 244 |
+
<div className="space-y-1">
|
| 245 |
+
<button
|
| 246 |
+
onClick={() => setSelectedProduct('all')}
|
| 247 |
+
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-left transition-colors ${
|
| 248 |
+
selectedProduct === 'all'
|
| 249 |
+
? 'bg-blue-50 text-blue-700'
|
| 250 |
+
: 'hover:bg-slate-50 text-slate-700'
|
| 251 |
+
}`}
|
| 252 |
+
>
|
| 253 |
+
<FolderOpen className="w-4 h-4" />
|
| 254 |
+
<span className="font-medium text-sm">All Assets</span>
|
| 255 |
+
<Badge variant="secondary" className="ml-auto text-xs">
|
| 256 |
+
{mockAssets.length}
|
| 257 |
+
</Badge>
|
| 258 |
+
</button>
|
| 259 |
+
|
| 260 |
+
{products.map(product => (
|
| 261 |
+
<div key={product.id}>
|
| 262 |
+
<button
|
| 263 |
+
onClick={() => {
|
| 264 |
+
setSelectedProduct(product.id);
|
| 265 |
+
if (product.subCategories.length > 0) {
|
| 266 |
+
toggleProduct(product.id);
|
| 267 |
+
}
|
| 268 |
+
}}
|
| 269 |
+
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-left transition-colors ${
|
| 270 |
+
selectedProduct === product.id
|
| 271 |
+
? `bg-${product.color}-50 text-${product.color}-700`
|
| 272 |
+
: 'hover:bg-slate-50 text-slate-700'
|
| 273 |
+
}`}
|
| 274 |
+
>
|
| 275 |
+
{product.subCategories.length > 0 && (
|
| 276 |
+
<ChevronRight className={`w-4 h-4 transition-transform ${
|
| 277 |
+
expandedProducts.includes(product.id) ? 'rotate-90' : ''
|
| 278 |
+
}`} />
|
| 279 |
+
)}
|
| 280 |
+
{product.subCategories.length === 0 && <div className="w-4" />}
|
| 281 |
+
<span className="font-medium text-sm truncate">{product.shortName}</span>
|
| 282 |
+
<Badge variant="secondary" className="ml-auto text-xs">
|
| 283 |
+
{getAssetsByProduct(product.id).length}
|
| 284 |
+
</Badge>
|
| 285 |
+
</button>
|
| 286 |
+
|
| 287 |
+
<AnimatePresence>
|
| 288 |
+
{expandedProducts.includes(product.id) && product.subCategories.length > 0 && (
|
| 289 |
+
<motion.div
|
| 290 |
+
initial={{ height: 0, opacity: 0 }}
|
| 291 |
+
animate={{ height: 'auto', opacity: 1 }}
|
| 292 |
+
exit={{ height: 0, opacity: 0 }}
|
| 293 |
+
className="overflow-hidden"
|
| 294 |
+
>
|
| 295 |
+
<div className="ml-7 pl-3 border-l border-slate-200 mt-1 space-y-1">
|
| 296 |
+
{product.subCategories.map((sub, idx) => (
|
| 297 |
+
<button
|
| 298 |
+
key={idx}
|
| 299 |
+
className="w-full text-left px-3 py-2 text-sm text-slate-600 hover:text-slate-900 hover:bg-slate-50 rounded-lg transition-colors"
|
| 300 |
+
>
|
| 301 |
+
{sub}
|
| 302 |
+
</button>
|
| 303 |
+
))}
|
| 304 |
+
</div>
|
| 305 |
+
</motion.div>
|
| 306 |
+
)}
|
| 307 |
+
</AnimatePresence>
|
| 308 |
+
</div>
|
| 309 |
+
))}
|
| 310 |
+
</div>
|
| 311 |
+
</CardContent>
|
| 312 |
+
</Card>
|
| 313 |
+
</motion.div>
|
| 314 |
+
|
| 315 |
+
{/* Main Content */}
|
| 316 |
+
<motion.div
|
| 317 |
+
initial={{ opacity: 0, y: 20 }}
|
| 318 |
+
animate={{ opacity: 1, y: 0 }}
|
| 319 |
+
className="lg:col-span-3"
|
| 320 |
+
>
|
| 321 |
+
{/* Search and Filters */}
|
| 322 |
+
<Card className="border-0 shadow-lg shadow-slate-200/50 mb-6">
|
| 323 |
+
<CardContent className="p-4">
|
| 324 |
+
<div className="flex flex-col sm:flex-row gap-3">
|
| 325 |
+
<div className="relative flex-1">
|
| 326 |
+
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
| 327 |
+
<Input
|
| 328 |
+
placeholder="Search assets..."
|
| 329 |
+
value={searchQuery}
|
| 330 |
+
onChange={(e) => setSearchQuery(e.target.value)}
|
| 331 |
+
className="pl-10 border-slate-200"
|
| 332 |
+
/>
|
| 333 |
+
</div>
|
| 334 |
+
<div className="flex gap-2">
|
| 335 |
+
<Select defaultValue="all">
|
| 336 |
+
<SelectTrigger className="w-32 border-slate-200">
|
| 337 |
+
<Filter className="w-4 h-4 mr-2" />
|
| 338 |
+
<SelectValue placeholder="Type" />
|
| 339 |
+
</SelectTrigger>
|
| 340 |
+
<SelectContent>
|
| 341 |
+
<SelectItem value="all">All Types</SelectItem>
|
| 342 |
+
<SelectItem value="image">Images</SelectItem>
|
| 343 |
+
<SelectItem value="document">Documents</SelectItem>
|
| 344 |
+
<SelectItem value="video">Videos</SelectItem>
|
| 345 |
+
</SelectContent>
|
| 346 |
+
</Select>
|
| 347 |
+
<div className="flex border border-slate-200 rounded-lg overflow-hidden">
|
| 348 |
+
<Button
|
| 349 |
+
variant={viewMode === 'grid' ? 'secondary' : 'ghost'}
|
| 350 |
+
size="icon"
|
| 351 |
+
onClick={() => setViewMode('grid')}
|
| 352 |
+
className="rounded-none"
|
| 353 |
+
>
|
| 354 |
+
<Grid3X3 className="w-4 h-4" />
|
| 355 |
+
</Button>
|
| 356 |
+
<Button
|
| 357 |
+
variant={viewMode === 'list' ? 'secondary' : 'ghost'}
|
| 358 |
+
size="icon"
|
| 359 |
+
onClick={() => setViewMode('list')}
|
| 360 |
+
className="rounded-none"
|
| 361 |
+
>
|
| 362 |
+
<List className="w-4 h-4" />
|
| 363 |
+
</Button>
|
| 364 |
+
</div>
|
| 365 |
+
</div>
|
| 366 |
+
</div>
|
| 367 |
+
</CardContent>
|
| 368 |
+
</Card>
|
| 369 |
+
|
| 370 |
+
{/* Assets Grid/List */}
|
| 371 |
+
{viewMode === 'grid' ? (
|
| 372 |
+
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4">
|
| 373 |
+
{filteredAssets.map((asset, index) => (
|
| 374 |
+
<motion.div
|
| 375 |
+
key={asset.id}
|
| 376 |
+
initial={{ opacity: 0, scale: 0.95 }}
|
| 377 |
+
animate={{ opacity: 1, scale: 1 }}
|
| 378 |
+
transition={{ delay: index * 0.05 }}
|
| 379 |
+
>
|
| 380 |
+
<Card className="border-0 shadow-md hover:shadow-lg transition-all duration-300 group overflow-hidden">
|
| 381 |
+
<div className={`h-40 bg-gradient-to-br from-${getProductColor(asset.product)}-100 to-${getProductColor(asset.product)}-50 flex items-center justify-center relative`}>
|
| 382 |
+
{asset.type === 'image' ? (
|
| 383 |
+
<Image className={`w-12 h-12 text-${getProductColor(asset.product)}-400`} />
|
| 384 |
+
) : asset.type === 'video' ? (
|
| 385 |
+
<Video className={`w-12 h-12 text-${getProductColor(asset.product)}-400`} />
|
| 386 |
+
) : (
|
| 387 |
+
<FileText className={`w-12 h-12 text-${getProductColor(asset.product)}-400`} />
|
| 388 |
+
)}
|
| 389 |
+
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors flex items-center justify-center opacity-0 group-hover:opacity-100">
|
| 390 |
+
<div className="flex gap-2">
|
| 391 |
+
<Button size="icon" variant="secondary" className="h-9 w-9">
|
| 392 |
+
<Eye className="w-4 h-4" />
|
| 393 |
+
</Button>
|
| 394 |
+
<Button size="icon" variant="secondary" className="h-9 w-9">
|
| 395 |
+
<Download className="w-4 h-4" />
|
| 396 |
+
</Button>
|
| 397 |
+
</div>
|
| 398 |
+
</div>
|
| 399 |
+
<div className="absolute top-2 right-2">
|
| 400 |
+
<Checkbox className="bg-white border-slate-300" />
|
| 401 |
+
</div>
|
| 402 |
+
</div>
|
| 403 |
+
<CardContent className="p-4">
|
| 404 |
+
<div className="flex items-start justify-between gap-2">
|
| 405 |
+
<div className="min-w-0">
|
| 406 |
+
<h3 className="font-medium text-slate-900 text-sm truncate">
|
| 407 |
+
{asset.name}
|
| 408 |
+
</h3>
|
| 409 |
+
<p className="text-xs text-slate-500 mt-1">
|
| 410 |
+
{asset.size} • {asset.date}
|
| 411 |
+
</p>
|
| 412 |
+
</div>
|
| 413 |
+
<DropdownMenu>
|
| 414 |
+
<DropdownMenuTrigger asChild>
|
| 415 |
+
<Button variant="ghost" size="icon" className="h-8 w-8 shrink-0">
|
| 416 |
+
<MoreVertical className="w-4 h-4" />
|
| 417 |
+
</Button>
|
| 418 |
+
</DropdownMenuTrigger>
|
| 419 |
+
<DropdownMenuContent align="end">
|
| 420 |
+
<DropdownMenuItem>
|
| 421 |
+
<Eye className="w-4 h-4 mr-2" /> Preview
|
| 422 |
+
</DropdownMenuItem>
|
| 423 |
+
<DropdownMenuItem>
|
| 424 |
+
<Download className="w-4 h-4 mr-2" /> Download
|
| 425 |
+
</DropdownMenuItem>
|
| 426 |
+
<DropdownMenuItem className="text-red-600">
|
| 427 |
+
<Trash2 className="w-4 h-4 mr-2" /> Delete
|
| 428 |
+
</DropdownMenuItem>
|
| 429 |
+
</DropdownMenuContent>
|
| 430 |
+
</DropdownMenu>
|
| 431 |
+
</div>
|
| 432 |
+
<div className="flex gap-2 mt-3">
|
| 433 |
+
<Badge variant="outline" className={`text-xs border-${getProductColor(asset.product)}-200 text-${getProductColor(asset.product)}-700 bg-${getProductColor(asset.product)}-50`}>
|
| 434 |
+
{products.find(p => p.id === asset.product)?.shortName}
|
| 435 |
+
</Badge>
|
| 436 |
+
{asset.subCategory && (
|
| 437 |
+
<Badge variant="outline" className="text-xs border-slate-200 text-slate-600 truncate max-w-[120px]">
|
| 438 |
+
{asset.subCategory}
|
| 439 |
+
</Badge>
|
| 440 |
+
)}
|
| 441 |
+
</div>
|
| 442 |
+
</CardContent>
|
| 443 |
+
</Card>
|
| 444 |
+
</motion.div>
|
| 445 |
+
))}
|
| 446 |
+
</div>
|
| 447 |
+
) : (
|
| 448 |
+
<Card className="border-0 shadow-lg shadow-slate-200/50">
|
| 449 |
+
<CardContent className="p-0">
|
| 450 |
+
<div className="divide-y divide-slate-100">
|
| 451 |
+
{filteredAssets.map((asset, index) => (
|
| 452 |
+
<motion.div
|
| 453 |
+
key={asset.id}
|
| 454 |
+
initial={{ opacity: 0, x: -10 }}
|
| 455 |
+
animate={{ opacity: 1, x: 0 }}
|
| 456 |
+
transition={{ delay: index * 0.03 }}
|
| 457 |
+
className="flex items-center gap-4 p-4 hover:bg-slate-50 transition-colors"
|
| 458 |
+
>
|
| 459 |
+
<Checkbox />
|
| 460 |
+
<div className={`w-10 h-10 rounded-lg bg-${getProductColor(asset.product)}-100 flex items-center justify-center`}>
|
| 461 |
+
{getTypeIcon(asset.type)}
|
| 462 |
+
</div>
|
| 463 |
+
<div className="flex-1 min-w-0">
|
| 464 |
+
<h3 className="font-medium text-slate-900 text-sm truncate">
|
| 465 |
+
{asset.name}
|
| 466 |
+
</h3>
|
| 467 |
+
<p className="text-xs text-slate-500 mt-0.5">
|
| 468 |
+
{asset.subCategory || products.find(p => p.id === asset.product)?.name}
|
| 469 |
+
</p>
|
| 470 |
+
</div>
|
| 471 |
+
<div className="hidden sm:block text-sm text-slate-500">
|
| 472 |
+
{asset.size}
|
| 473 |
+
</div>
|
| 474 |
+
<div className="hidden md:block text-sm text-slate-500">
|
| 475 |
+
{asset.date}
|
| 476 |
+
</div>
|
| 477 |
+
<Badge variant="outline" className={`text-xs border-${getProductColor(asset.product)}-200 text-${getProductColor(asset.product)}-700`}>
|
| 478 |
+
{products.find(p => p.id === asset.product)?.shortName}
|
| 479 |
+
</Badge>
|
| 480 |
+
<DropdownMenu>
|
| 481 |
+
<DropdownMenuTrigger asChild>
|
| 482 |
+
<Button variant="ghost" size="icon" className="h-8 w-8">
|
| 483 |
+
<MoreVertical className="w-4 h-4" />
|
| 484 |
+
</Button>
|
| 485 |
+
</DropdownMenuTrigger>
|
| 486 |
+
<DropdownMenuContent align="end">
|
| 487 |
+
<DropdownMenuItem>
|
| 488 |
+
<Eye className="w-4 h-4 mr-2" /> Preview
|
| 489 |
+
</DropdownMenuItem>
|
| 490 |
+
<DropdownMenuItem>
|
| 491 |
+
<Download className="w-4 h-4 mr-2" /> Download
|
| 492 |
+
</DropdownMenuItem>
|
| 493 |
+
<DropdownMenuItem className="text-red-600">
|
| 494 |
+
<Trash2 className="w-4 h-4 mr-2" /> Delete
|
| 495 |
+
</DropdownMenuItem>
|
| 496 |
+
</DropdownMenuContent>
|
| 497 |
+
</DropdownMenu>
|
| 498 |
+
</motion.div>
|
| 499 |
+
))}
|
| 500 |
+
</div>
|
| 501 |
+
</CardContent>
|
| 502 |
+
</Card>
|
| 503 |
+
)}
|
| 504 |
+
</motion.div>
|
| 505 |
+
</div>
|
| 506 |
+
</div>
|
| 507 |
+
</div>
|
| 508 |
+
);
|
| 509 |
+
}
|
| 510 |
+
|
frontend/src/pages/Scheduler.jsx
ADDED
|
@@ -0,0 +1,493 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState } from 'react';
|
| 2 |
+
import { motion, AnimatePresence } from 'framer-motion';
|
| 3 |
+
import {
|
| 4 |
+
Calendar as CalendarIcon,
|
| 5 |
+
ChevronLeft,
|
| 6 |
+
ChevronRight,
|
| 7 |
+
Plus,
|
| 8 |
+
Clock,
|
| 9 |
+
Layers,
|
| 10 |
+
Image,
|
| 11 |
+
FileText,
|
| 12 |
+
Video,
|
| 13 |
+
Filter,
|
| 14 |
+
Settings,
|
| 15 |
+
Sparkles,
|
| 16 |
+
Check,
|
| 17 |
+
X,
|
| 18 |
+
GripVertical
|
| 19 |
+
} from 'lucide-react';
|
| 20 |
+
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
| 21 |
+
import { Button } from '@/components/ui/button';
|
| 22 |
+
import { Badge } from '@/components/ui/badge';
|
| 23 |
+
import { Calendar } from '@/components/ui/calendar';
|
| 24 |
+
import { Checkbox } from '@/components/ui/checkbox';
|
| 25 |
+
import { Label } from '@/components/ui/label';
|
| 26 |
+
import { Slider } from '@/components/ui/slider';
|
| 27 |
+
import {
|
| 28 |
+
Select,
|
| 29 |
+
SelectContent,
|
| 30 |
+
SelectItem,
|
| 31 |
+
SelectTrigger,
|
| 32 |
+
SelectValue,
|
| 33 |
+
} from '@/components/ui/select';
|
| 34 |
+
import {
|
| 35 |
+
Dialog,
|
| 36 |
+
DialogContent,
|
| 37 |
+
DialogHeader,
|
| 38 |
+
DialogTitle,
|
| 39 |
+
DialogTrigger,
|
| 40 |
+
} from '@/components/ui/dialog';
|
| 41 |
+
import {
|
| 42 |
+
Popover,
|
| 43 |
+
PopoverContent,
|
| 44 |
+
PopoverTrigger,
|
| 45 |
+
} from '@/components/ui/popover';
|
| 46 |
+
import { format, addDays, startOfWeek, endOfWeek, eachDayOfInterval, isSameDay, isToday } from 'date-fns';
|
| 47 |
+
import { Link } from 'react-router-dom';
|
| 48 |
+
import { createPageUrl } from '@/utils';
|
| 49 |
+
|
| 50 |
+
const products = [
|
| 51 |
+
{ id: 'ocr', name: 'Document Parsing (OCR)', shortName: 'OCR', color: 'blue' },
|
| 52 |
+
{ id: 'p2p', name: 'Purchase To Pay', shortName: 'P2P', color: 'emerald', subCategories: ['Budget Approval', 'Purchase Request', 'Accounts Payable'] },
|
| 53 |
+
{ id: 'o2c', name: 'Order to Cash', shortName: 'O2C', color: 'violet', subCategories: ['Quotation', 'Sales Order', 'PickSlip Delivery', 'Accounts Receivable'] },
|
| 54 |
+
];
|
| 55 |
+
|
| 56 |
+
const postTypes = [
|
| 57 |
+
{ id: 'carousel', name: 'Carousel', icon: Layers, description: 'Multi-slide visual story' },
|
| 58 |
+
{ id: 'cover_content', name: 'Cover Image + Content', icon: Image, description: 'Featured image with text' },
|
| 59 |
+
{ id: 'content_only', name: 'Content Only', icon: FileText, description: 'Text-based post' },
|
| 60 |
+
{ id: 'webinar', name: 'Webinar Invite', icon: Video, description: 'Event promotion' },
|
| 61 |
+
];
|
| 62 |
+
|
| 63 |
+
const scheduledPosts = [
|
| 64 |
+
{ id: 1, title: 'OCR Automation Benefits', type: 'carousel', product: 'ocr', time: '10:00 AM', date: new Date(), status: 'scheduled' },
|
| 65 |
+
{ id: 2, title: 'P2P Efficiency Guide', type: 'cover_content', product: 'p2p', time: '2:00 PM', date: new Date(), status: 'draft' },
|
| 66 |
+
{ id: 3, title: 'O2C Webinar', type: 'webinar', product: 'o2c', time: '11:00 AM', date: addDays(new Date(), 1), status: 'scheduled' },
|
| 67 |
+
{ id: 4, title: 'Invoice Processing Tips', type: 'content_only', product: 'ocr', time: '3:00 PM', date: addDays(new Date(), 1), status: 'scheduled' },
|
| 68 |
+
{ id: 5, title: 'Budget Approval Flow', type: 'carousel', product: 'p2p', time: '9:00 AM', date: addDays(new Date(), 2), status: 'scheduled' },
|
| 69 |
+
{ id: 6, title: 'Sales Order Demo', type: 'cover_content', product: 'o2c', time: '1:00 PM', date: addDays(new Date(), 3), status: 'draft' },
|
| 70 |
+
];
|
| 71 |
+
|
| 72 |
+
export default function Scheduler() {
|
| 73 |
+
const [selectedDate, setSelectedDate] = useState(new Date());
|
| 74 |
+
const [currentWeekStart, setCurrentWeekStart] = useState(startOfWeek(new Date(), { weekStartsOn: 1 }));
|
| 75 |
+
const [campaignDialogOpen, setCampaignDialogOpen] = useState(false);
|
| 76 |
+
const [selectedProducts, setSelectedProducts] = useState(['ocr', 'p2p', 'o2c']);
|
| 77 |
+
const [selectedPostTypes, setSelectedPostTypes] = useState(['carousel', 'cover_content']);
|
| 78 |
+
const [dateRange, setDateRange] = useState({ from: new Date(), to: addDays(new Date(), 30) });
|
| 79 |
+
const [postsPerWeek, setPostsPerWeek] = useState([5]);
|
| 80 |
+
|
| 81 |
+
const weekDays = eachDayOfInterval({
|
| 82 |
+
start: currentWeekStart,
|
| 83 |
+
end: endOfWeek(currentWeekStart, { weekStartsOn: 1 })
|
| 84 |
+
});
|
| 85 |
+
|
| 86 |
+
const navigateWeek = (direction) => {
|
| 87 |
+
setCurrentWeekStart(prev => addDays(prev, direction === 'next' ? 7 : -7));
|
| 88 |
+
};
|
| 89 |
+
|
| 90 |
+
const getPostsForDate = (date) => {
|
| 91 |
+
return scheduledPosts.filter(post => isSameDay(post.date, date));
|
| 92 |
+
};
|
| 93 |
+
|
| 94 |
+
const getProductColor = (productId) => {
|
| 95 |
+
const product = products.find(p => p.id === productId);
|
| 96 |
+
return product?.color || 'slate';
|
| 97 |
+
};
|
| 98 |
+
|
| 99 |
+
const getPostTypeIcon = (typeId) => {
|
| 100 |
+
const type = postTypes.find(t => t.id === typeId);
|
| 101 |
+
return type?.icon || FileText;
|
| 102 |
+
};
|
| 103 |
+
|
| 104 |
+
const toggleProduct = (productId) => {
|
| 105 |
+
setSelectedProducts(prev =>
|
| 106 |
+
prev.includes(productId)
|
| 107 |
+
? prev.filter(id => id !== productId)
|
| 108 |
+
: [...prev, productId]
|
| 109 |
+
);
|
| 110 |
+
};
|
| 111 |
+
|
| 112 |
+
const togglePostType = (typeId) => {
|
| 113 |
+
setSelectedPostTypes(prev =>
|
| 114 |
+
prev.includes(typeId)
|
| 115 |
+
? prev.filter(id => id !== typeId)
|
| 116 |
+
: [...prev, typeId]
|
| 117 |
+
);
|
| 118 |
+
};
|
| 119 |
+
|
| 120 |
+
return (
|
| 121 |
+
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-blue-50/30">
|
| 122 |
+
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
| 123 |
+
{/* Header */}
|
| 124 |
+
<motion.div
|
| 125 |
+
initial={{ opacity: 0, y: -20 }}
|
| 126 |
+
animate={{ opacity: 1, y: 0 }}
|
| 127 |
+
className="mb-8"
|
| 128 |
+
>
|
| 129 |
+
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
| 130 |
+
<div>
|
| 131 |
+
<h1 className="text-3xl font-bold text-slate-900 tracking-tight">
|
| 132 |
+
Content Scheduler
|
| 133 |
+
</h1>
|
| 134 |
+
<p className="text-slate-500 mt-1">
|
| 135 |
+
Plan and schedule your LinkedIn content calendar
|
| 136 |
+
</p>
|
| 137 |
+
</div>
|
| 138 |
+
<div className="flex gap-3">
|
| 139 |
+
<Dialog open={campaignDialogOpen} onOpenChange={setCampaignDialogOpen}>
|
| 140 |
+
<DialogTrigger asChild>
|
| 141 |
+
<Button variant="outline" className="gap-2 border-slate-200">
|
| 142 |
+
<Settings className="w-4 h-4" />
|
| 143 |
+
Campaign Settings
|
| 144 |
+
</Button>
|
| 145 |
+
</DialogTrigger>
|
| 146 |
+
<DialogContent className="sm:max-w-2xl max-h-[90vh] overflow-y-auto">
|
| 147 |
+
<DialogHeader>
|
| 148 |
+
<DialogTitle className="flex items-center gap-2">
|
| 149 |
+
<Sparkles className="w-5 h-5 text-amber-500" />
|
| 150 |
+
Configure Campaign
|
| 151 |
+
</DialogTitle>
|
| 152 |
+
</DialogHeader>
|
| 153 |
+
<div className="space-y-6 pt-4">
|
| 154 |
+
{/* Date Range */}
|
| 155 |
+
<div className="space-y-3">
|
| 156 |
+
<Label className="text-sm font-semibold">Campaign Date Range</Label>
|
| 157 |
+
<div className="grid grid-cols-2 gap-4">
|
| 158 |
+
<div>
|
| 159 |
+
<Label className="text-xs text-slate-500">Start Date</Label>
|
| 160 |
+
<Popover>
|
| 161 |
+
<PopoverTrigger asChild>
|
| 162 |
+
<Button variant="outline" className="w-full justify-start mt-1.5">
|
| 163 |
+
<CalendarIcon className="w-4 h-4 mr-2" />
|
| 164 |
+
{format(dateRange.from, 'MMM d, yyyy')}
|
| 165 |
+
</Button>
|
| 166 |
+
</PopoverTrigger>
|
| 167 |
+
<PopoverContent className="w-auto p-0" align="start">
|
| 168 |
+
<Calendar
|
| 169 |
+
mode="single"
|
| 170 |
+
selected={dateRange.from}
|
| 171 |
+
onSelect={(date) => setDateRange(prev => ({ ...prev, from: date }))}
|
| 172 |
+
/>
|
| 173 |
+
</PopoverContent>
|
| 174 |
+
</Popover>
|
| 175 |
+
</div>
|
| 176 |
+
<div>
|
| 177 |
+
<Label className="text-xs text-slate-500">End Date</Label>
|
| 178 |
+
<Popover>
|
| 179 |
+
<PopoverTrigger asChild>
|
| 180 |
+
<Button variant="outline" className="w-full justify-start mt-1.5">
|
| 181 |
+
<CalendarIcon className="w-4 h-4 mr-2" />
|
| 182 |
+
{format(dateRange.to, 'MMM d, yyyy')}
|
| 183 |
+
</Button>
|
| 184 |
+
</PopoverTrigger>
|
| 185 |
+
<PopoverContent className="w-auto p-0" align="start">
|
| 186 |
+
<Calendar
|
| 187 |
+
mode="single"
|
| 188 |
+
selected={dateRange.to}
|
| 189 |
+
onSelect={(date) => setDateRange(prev => ({ ...prev, to: date }))}
|
| 190 |
+
/>
|
| 191 |
+
</PopoverContent>
|
| 192 |
+
</Popover>
|
| 193 |
+
</div>
|
| 194 |
+
</div>
|
| 195 |
+
</div>
|
| 196 |
+
|
| 197 |
+
{/* Products to Focus */}
|
| 198 |
+
<div className="space-y-3">
|
| 199 |
+
<Label className="text-sm font-semibold">Products to Focus</Label>
|
| 200 |
+
<div className="grid gap-3">
|
| 201 |
+
{products.map(product => (
|
| 202 |
+
<div key={product.id} className="space-y-2">
|
| 203 |
+
<div
|
| 204 |
+
onClick={() => toggleProduct(product.id)}
|
| 205 |
+
className={`flex items-center gap-3 p-3 rounded-xl border cursor-pointer transition-all ${
|
| 206 |
+
selectedProducts.includes(product.id)
|
| 207 |
+
? `border-${product.color}-300 bg-${product.color}-50`
|
| 208 |
+
: 'border-slate-200 hover:border-slate-300'
|
| 209 |
+
}`}
|
| 210 |
+
>
|
| 211 |
+
<div className={`w-8 h-8 rounded-lg flex items-center justify-center ${
|
| 212 |
+
selectedProducts.includes(product.id)
|
| 213 |
+
? `bg-${product.color}-500 text-white`
|
| 214 |
+
: 'bg-slate-100 text-slate-400'
|
| 215 |
+
}`}>
|
| 216 |
+
{selectedProducts.includes(product.id) ? (
|
| 217 |
+
<Check className="w-4 h-4" />
|
| 218 |
+
) : (
|
| 219 |
+
<Plus className="w-4 h-4" />
|
| 220 |
+
)}
|
| 221 |
+
</div>
|
| 222 |
+
<div className="flex-1">
|
| 223 |
+
<p className="font-medium text-slate-900">{product.name}</p>
|
| 224 |
+
{product.subCategories && (
|
| 225 |
+
<p className="text-xs text-slate-500 mt-0.5">
|
| 226 |
+
{product.subCategories.join(', ')}
|
| 227 |
+
</p>
|
| 228 |
+
)}
|
| 229 |
+
</div>
|
| 230 |
+
</div>
|
| 231 |
+
</div>
|
| 232 |
+
))}
|
| 233 |
+
</div>
|
| 234 |
+
</div>
|
| 235 |
+
|
| 236 |
+
{/* Post Types Mix */}
|
| 237 |
+
<div className="space-y-3">
|
| 238 |
+
<Label className="text-sm font-semibold">Post Types Mix</Label>
|
| 239 |
+
<div className="grid grid-cols-2 gap-3">
|
| 240 |
+
{postTypes.map(type => (
|
| 241 |
+
<div
|
| 242 |
+
key={type.id}
|
| 243 |
+
onClick={() => togglePostType(type.id)}
|
| 244 |
+
className={`flex items-center gap-3 p-3 rounded-xl border cursor-pointer transition-all ${
|
| 245 |
+
selectedPostTypes.includes(type.id)
|
| 246 |
+
? 'border-blue-300 bg-blue-50'
|
| 247 |
+
: 'border-slate-200 hover:border-slate-300'
|
| 248 |
+
}`}
|
| 249 |
+
>
|
| 250 |
+
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${
|
| 251 |
+
selectedPostTypes.includes(type.id)
|
| 252 |
+
? 'bg-blue-500 text-white'
|
| 253 |
+
: 'bg-slate-100 text-slate-400'
|
| 254 |
+
}`}>
|
| 255 |
+
<type.icon className="w-5 h-5" />
|
| 256 |
+
</div>
|
| 257 |
+
<div>
|
| 258 |
+
<p className="font-medium text-sm text-slate-900">{type.name}</p>
|
| 259 |
+
<p className="text-xs text-slate-500">{type.description}</p>
|
| 260 |
+
</div>
|
| 261 |
+
</div>
|
| 262 |
+
))}
|
| 263 |
+
</div>
|
| 264 |
+
</div>
|
| 265 |
+
|
| 266 |
+
{/* Posting Frequency */}
|
| 267 |
+
<div className="space-y-3">
|
| 268 |
+
<div className="flex items-center justify-between">
|
| 269 |
+
<Label className="text-sm font-semibold">Posts per Week</Label>
|
| 270 |
+
<span className="text-lg font-bold text-blue-600">{postsPerWeek[0]}</span>
|
| 271 |
+
</div>
|
| 272 |
+
<Slider
|
| 273 |
+
value={postsPerWeek}
|
| 274 |
+
onValueChange={setPostsPerWeek}
|
| 275 |
+
min={1}
|
| 276 |
+
max={14}
|
| 277 |
+
step={1}
|
| 278 |
+
className="py-4"
|
| 279 |
+
/>
|
| 280 |
+
<div className="flex justify-between text-xs text-slate-500">
|
| 281 |
+
<span>1 post</span>
|
| 282 |
+
<span>14 posts</span>
|
| 283 |
+
</div>
|
| 284 |
+
</div>
|
| 285 |
+
|
| 286 |
+
<div className="flex justify-end gap-2 pt-4 border-t">
|
| 287 |
+
<Button variant="outline" onClick={() => setCampaignDialogOpen(false)}>
|
| 288 |
+
Cancel
|
| 289 |
+
</Button>
|
| 290 |
+
<Button className="bg-gradient-to-r from-blue-600 to-indigo-600 gap-2">
|
| 291 |
+
<Sparkles className="w-4 h-4" />
|
| 292 |
+
Generate Schedule
|
| 293 |
+
</Button>
|
| 294 |
+
</div>
|
| 295 |
+
</div>
|
| 296 |
+
</DialogContent>
|
| 297 |
+
</Dialog>
|
| 298 |
+
|
| 299 |
+
<Link to={createPageUrl('PostEditor')}>
|
| 300 |
+
<Button className="gap-2 bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700 shadow-lg shadow-blue-500/25">
|
| 301 |
+
<Plus className="w-4 h-4" />
|
| 302 |
+
Create Post
|
| 303 |
+
</Button>
|
| 304 |
+
</Link>
|
| 305 |
+
</div>
|
| 306 |
+
</div>
|
| 307 |
+
</motion.div>
|
| 308 |
+
|
| 309 |
+
<div className="grid lg:grid-cols-4 gap-6">
|
| 310 |
+
{/* Calendar Sidebar */}
|
| 311 |
+
<motion.div
|
| 312 |
+
initial={{ opacity: 0, x: -20 }}
|
| 313 |
+
animate={{ opacity: 1, x: 0 }}
|
| 314 |
+
className="lg:col-span-1 space-y-6"
|
| 315 |
+
>
|
| 316 |
+
<Card className="border-0 shadow-lg shadow-slate-200/50">
|
| 317 |
+
<CardContent className="p-4">
|
| 318 |
+
<Calendar
|
| 319 |
+
mode="single"
|
| 320 |
+
selected={selectedDate}
|
| 321 |
+
onSelect={setSelectedDate}
|
| 322 |
+
className="rounded-md"
|
| 323 |
+
/>
|
| 324 |
+
</CardContent>
|
| 325 |
+
</Card>
|
| 326 |
+
|
| 327 |
+
{/* Quick Stats */}
|
| 328 |
+
<Card className="border-0 shadow-lg shadow-slate-200/50">
|
| 329 |
+
<CardHeader className="pb-3">
|
| 330 |
+
<CardTitle className="text-sm font-semibold text-slate-600">This Week</CardTitle>
|
| 331 |
+
</CardHeader>
|
| 332 |
+
<CardContent className="space-y-3">
|
| 333 |
+
<div className="flex items-center justify-between">
|
| 334 |
+
<span className="text-sm text-slate-600">Scheduled</span>
|
| 335 |
+
<Badge className="bg-blue-100 text-blue-700 border-0">12 posts</Badge>
|
| 336 |
+
</div>
|
| 337 |
+
<div className="flex items-center justify-between">
|
| 338 |
+
<span className="text-sm text-slate-600">Drafts</span>
|
| 339 |
+
<Badge className="bg-amber-100 text-amber-700 border-0">4 posts</Badge>
|
| 340 |
+
</div>
|
| 341 |
+
<div className="flex items-center justify-between">
|
| 342 |
+
<span className="text-sm text-slate-600">Published</span>
|
| 343 |
+
<Badge className="bg-emerald-100 text-emerald-700 border-0">8 posts</Badge>
|
| 344 |
+
</div>
|
| 345 |
+
</CardContent>
|
| 346 |
+
</Card>
|
| 347 |
+
|
| 348 |
+
{/* Product Filter */}
|
| 349 |
+
<Card className="border-0 shadow-lg shadow-slate-200/50">
|
| 350 |
+
<CardHeader className="pb-3">
|
| 351 |
+
<CardTitle className="text-sm font-semibold text-slate-600 flex items-center gap-2">
|
| 352 |
+
<Filter className="w-4 h-4" />
|
| 353 |
+
Filter by Product
|
| 354 |
+
</CardTitle>
|
| 355 |
+
</CardHeader>
|
| 356 |
+
<CardContent className="space-y-2">
|
| 357 |
+
{products.map(product => (
|
| 358 |
+
<label key={product.id} className="flex items-center gap-3 cursor-pointer">
|
| 359 |
+
<Checkbox defaultChecked />
|
| 360 |
+
<span className="text-sm text-slate-700">{product.shortName}</span>
|
| 361 |
+
<div className={`w-2 h-2 rounded-full bg-${product.color}-500 ml-auto`} />
|
| 362 |
+
</label>
|
| 363 |
+
))}
|
| 364 |
+
</CardContent>
|
| 365 |
+
</Card>
|
| 366 |
+
</motion.div>
|
| 367 |
+
|
| 368 |
+
{/* Week View */}
|
| 369 |
+
<motion.div
|
| 370 |
+
initial={{ opacity: 0, y: 20 }}
|
| 371 |
+
animate={{ opacity: 1, y: 0 }}
|
| 372 |
+
className="lg:col-span-3"
|
| 373 |
+
>
|
| 374 |
+
<Card className="border-0 shadow-lg shadow-slate-200/50">
|
| 375 |
+
<CardHeader className="pb-4 border-b border-slate-100">
|
| 376 |
+
<div className="flex items-center justify-between">
|
| 377 |
+
<CardTitle className="text-lg font-semibold">
|
| 378 |
+
{format(currentWeekStart, 'MMMM yyyy')}
|
| 379 |
+
</CardTitle>
|
| 380 |
+
<div className="flex items-center gap-2">
|
| 381 |
+
<Button
|
| 382 |
+
variant="outline"
|
| 383 |
+
size="icon"
|
| 384 |
+
onClick={() => navigateWeek('prev')}
|
| 385 |
+
className="h-8 w-8"
|
| 386 |
+
>
|
| 387 |
+
<ChevronLeft className="w-4 h-4" />
|
| 388 |
+
</Button>
|
| 389 |
+
<Button
|
| 390 |
+
variant="outline"
|
| 391 |
+
size="sm"
|
| 392 |
+
onClick={() => setCurrentWeekStart(startOfWeek(new Date(), { weekStartsOn: 1 }))}
|
| 393 |
+
>
|
| 394 |
+
Today
|
| 395 |
+
</Button>
|
| 396 |
+
<Button
|
| 397 |
+
variant="outline"
|
| 398 |
+
size="icon"
|
| 399 |
+
onClick={() => navigateWeek('next')}
|
| 400 |
+
className="h-8 w-8"
|
| 401 |
+
>
|
| 402 |
+
<ChevronRight className="w-4 h-4" />
|
| 403 |
+
</Button>
|
| 404 |
+
</div>
|
| 405 |
+
</div>
|
| 406 |
+
</CardHeader>
|
| 407 |
+
<CardContent className="p-0">
|
| 408 |
+
<div className="grid grid-cols-7 border-b border-slate-100">
|
| 409 |
+
{weekDays.map((day, index) => (
|
| 410 |
+
<div
|
| 411 |
+
key={index}
|
| 412 |
+
className={`text-center py-4 border-r last:border-r-0 border-slate-100 ${
|
| 413 |
+
isToday(day) ? 'bg-blue-50' : ''
|
| 414 |
+
}`}
|
| 415 |
+
>
|
| 416 |
+
<p className="text-xs text-slate-500 uppercase">
|
| 417 |
+
{format(day, 'EEE')}
|
| 418 |
+
</p>
|
| 419 |
+
<p className={`text-lg font-semibold mt-1 ${
|
| 420 |
+
isToday(day) ? 'text-blue-600' : 'text-slate-900'
|
| 421 |
+
}`}>
|
| 422 |
+
{format(day, 'd')}
|
| 423 |
+
</p>
|
| 424 |
+
</div>
|
| 425 |
+
))}
|
| 426 |
+
</div>
|
| 427 |
+
<div className="grid grid-cols-7 min-h-[400px]">
|
| 428 |
+
{weekDays.map((day, dayIndex) => {
|
| 429 |
+
const dayPosts = getPostsForDate(day);
|
| 430 |
+
return (
|
| 431 |
+
<div
|
| 432 |
+
key={dayIndex}
|
| 433 |
+
className={`border-r last:border-r-0 border-slate-100 p-2 ${
|
| 434 |
+
isToday(day) ? 'bg-blue-50/30' : ''
|
| 435 |
+
}`}
|
| 436 |
+
>
|
| 437 |
+
<AnimatePresence>
|
| 438 |
+
{dayPosts.map((post, postIndex) => {
|
| 439 |
+
const PostIcon = getPostTypeIcon(post.type);
|
| 440 |
+
const color = getProductColor(post.product);
|
| 441 |
+
return (
|
| 442 |
+
<motion.div
|
| 443 |
+
key={post.id}
|
| 444 |
+
initial={{ opacity: 0, scale: 0.9 }}
|
| 445 |
+
animate={{ opacity: 1, scale: 1 }}
|
| 446 |
+
exit={{ opacity: 0, scale: 0.9 }}
|
| 447 |
+
transition={{ delay: postIndex * 0.05 }}
|
| 448 |
+
>
|
| 449 |
+
<Link to={createPageUrl('PostEditor') + `?id=${post.id}`}>
|
| 450 |
+
<div className={`mb-2 p-2 rounded-lg bg-white border border-${color}-200 hover:border-${color}-300 shadow-sm hover:shadow-md transition-all cursor-pointer group`}>
|
| 451 |
+
<div className="flex items-center gap-1.5 mb-1">
|
| 452 |
+
<div className={`w-5 h-5 rounded flex items-center justify-center bg-${color}-100`}>
|
| 453 |
+
<PostIcon className={`w-3 h-3 text-${color}-600`} />
|
| 454 |
+
</div>
|
| 455 |
+
<span className="text-[10px] text-slate-500">{post.time}</span>
|
| 456 |
+
</div>
|
| 457 |
+
<p className="text-xs font-medium text-slate-800 line-clamp-2 leading-tight">
|
| 458 |
+
{post.title}
|
| 459 |
+
</p>
|
| 460 |
+
<div className="flex items-center justify-between mt-1.5">
|
| 461 |
+
<Badge className={`text-[9px] px-1 py-0 bg-${color}-100 text-${color}-700 border-0`}>
|
| 462 |
+
{products.find(p => p.id === post.product)?.shortName}
|
| 463 |
+
</Badge>
|
| 464 |
+
{post.status === 'draft' && (
|
| 465 |
+
<span className="text-[9px] text-amber-600">Draft</span>
|
| 466 |
+
)}
|
| 467 |
+
</div>
|
| 468 |
+
</div>
|
| 469 |
+
</Link>
|
| 470 |
+
</motion.div>
|
| 471 |
+
);
|
| 472 |
+
})}
|
| 473 |
+
</AnimatePresence>
|
| 474 |
+
<Button
|
| 475 |
+
variant="ghost"
|
| 476 |
+
size="sm"
|
| 477 |
+
className="w-full h-7 text-xs text-slate-400 hover:text-slate-600 opacity-0 hover:opacity-100"
|
| 478 |
+
>
|
| 479 |
+
<Plus className="w-3 h-3 mr-1" />
|
| 480 |
+
Add
|
| 481 |
+
</Button>
|
| 482 |
+
</div>
|
| 483 |
+
);
|
| 484 |
+
})}
|
| 485 |
+
</div>
|
| 486 |
+
</CardContent>
|
| 487 |
+
</Card>
|
| 488 |
+
</motion.div>
|
| 489 |
+
</div>
|
| 490 |
+
</div>
|
| 491 |
+
</div>
|
| 492 |
+
);
|
| 493 |
+
}
|
frontend/src/utils.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { clsx } from "clsx";
|
| 2 |
+
import { twMerge } from "tailwind-merge";
|
| 3 |
+
|
| 4 |
+
export function cn(...inputs) {
|
| 5 |
+
return twMerge(clsx(inputs));
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
export function createPageUrl(pageName) {
|
| 9 |
+
const routes = {
|
| 10 |
+
Dashboard: '/',
|
| 11 |
+
Repository: '/repository',
|
| 12 |
+
Scheduler: '/scheduler',
|
| 13 |
+
PostEditor: '/post-editor',
|
| 14 |
+
Integrations: '/integrations',
|
| 15 |
+
};
|
| 16 |
+
return routes[pageName] || '/';
|
| 17 |
+
}
|
| 18 |
+
|
frontend/tailwind.config.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/** @type {import('tailwindcss').Config} */
|
| 2 |
+
export default {
|
| 3 |
+
content: [
|
| 4 |
+
"./index.html",
|
| 5 |
+
"./src/**/*.{js,ts,jsx,tsx}",
|
| 6 |
+
],
|
| 7 |
+
theme: {
|
| 8 |
+
extend: {},
|
| 9 |
+
},
|
| 10 |
+
plugins: [],
|
| 11 |
+
}
|
| 12 |
+
|
frontend/vite.config.js
CHANGED
|
@@ -1,9 +1,15 @@
|
|
| 1 |
import { defineConfig } from "vite";
|
| 2 |
import react from "@vitejs/plugin-react";
|
|
|
|
| 3 |
|
| 4 |
// IMPORTANT: proxy API calls to backend when running both inside one container
|
| 5 |
export default defineConfig({
|
| 6 |
plugins: [react()],
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
server: {
|
| 8 |
proxy: {
|
| 9 |
"/api": "http://127.0.0.1:8000"
|
|
|
|
| 1 |
import { defineConfig } from "vite";
|
| 2 |
import react from "@vitejs/plugin-react";
|
| 3 |
+
import path from "path";
|
| 4 |
|
| 5 |
// IMPORTANT: proxy API calls to backend when running both inside one container
|
| 6 |
export default defineConfig({
|
| 7 |
plugins: [react()],
|
| 8 |
+
resolve: {
|
| 9 |
+
alias: {
|
| 10 |
+
"@": path.resolve(__dirname, "./src"),
|
| 11 |
+
},
|
| 12 |
+
},
|
| 13 |
server: {
|
| 14 |
proxy: {
|
| 15 |
"/api": "http://127.0.0.1:8000"
|