Initial commit
This commit is contained in:
204
skills/add-odoo-model/SKILL.md
Normal file
204
skills/add-odoo-model/SKILL.md
Normal file
@@ -0,0 +1,204 @@
|
||||
---
|
||||
name: add-odoo-model
|
||||
description: Add integration for an additional Odoo Studio model to an existing Odoo PWA project. Use when user wants to add support for another model, mentions "add new model", "integrate another Odoo model", or similar.
|
||||
allowed-tools: Read, Write, Edit, Glob
|
||||
---
|
||||
|
||||
# Add Odoo Model Integration
|
||||
|
||||
Add a new Odoo model integration to an existing Odoo PWA project, creating cache stores, API methods, and UI components.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Existing Odoo PWA project (generated with create-odoo-pwa skill)
|
||||
- New Odoo Studio model created with `x_` prefix
|
||||
- Model name and display name from user
|
||||
|
||||
## Required User Input
|
||||
|
||||
Ask the user for:
|
||||
|
||||
1. **Model name** (required)
|
||||
- Format: without `x_` prefix (e.g., "inventory", "tasks")
|
||||
- Example: If Odoo model is `x_inventory`, user provides: `inventory`
|
||||
|
||||
2. **Model display name** (required)
|
||||
- Human-readable singular name (e.g., "Inventory Item", "Task")
|
||||
|
||||
3. **Create UI pages** (optional)
|
||||
- Ask if user wants to generate form and list pages
|
||||
- Default: yes
|
||||
|
||||
## Detection Steps
|
||||
|
||||
Before generating, detect the project structure:
|
||||
|
||||
1. **Detect framework**:
|
||||
- Check for `svelte.config.js` → SvelteKit
|
||||
- Check for `vite.config.ts` with React → React
|
||||
- Check for `nuxt.config.ts` → Vue/Nuxt
|
||||
|
||||
2. **Find existing files**:
|
||||
- Locate `src/lib/odoo.js` (or equivalent)
|
||||
- Find existing cache stores in `src/lib/stores/`
|
||||
- Check routes structure
|
||||
|
||||
3. **Verify Odoo connection**:
|
||||
- Check `.env` file has ODOO_URL and credentials
|
||||
|
||||
## Generation Steps
|
||||
|
||||
### Step 1: Create Cache Store
|
||||
|
||||
Generate `src/lib/stores/{{MODEL_NAME}}Cache.js`:
|
||||
|
||||
- Based on existing cache store pattern
|
||||
- Replace model name throughout
|
||||
- Update fields array with model-specific fields
|
||||
- Include CRUD methods
|
||||
|
||||
### Step 2: Update Odoo API Client
|
||||
|
||||
Add model-specific methods to `src/lib/odoo.js`:
|
||||
|
||||
```javascript
|
||||
/**
|
||||
* Fetch {{MODEL_DISPLAY_NAME}} records
|
||||
*/
|
||||
async fetch{{MODEL_NAME|capitalize}}s(domain = [], fields = []) {
|
||||
return await this.searchRecords('x_{{MODEL_NAME}}', domain, fields);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create {{MODEL_DISPLAY_NAME}}
|
||||
*/
|
||||
async create{{MODEL_NAME|capitalize}}(fields) {
|
||||
return await this.createRecord('x_{{MODEL_NAME}}', fields);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update {{MODEL_DISPLAY_NAME}}
|
||||
*/
|
||||
async update{{MODEL_NAME|capitalize}}(id, values) {
|
||||
return await this.updateRecord('x_{{MODEL_NAME}}', id, values);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete {{MODEL_DISPLAY_NAME}}
|
||||
*/
|
||||
async delete{{MODEL_NAME|capitalize}}(id) {
|
||||
return await this.deleteRecord('x_{{MODEL_NAME}}', id);
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: Create UI Pages (if requested)
|
||||
|
||||
#### Add Form Page: `src/routes/{{MODEL_NAME}}/+page.svelte`
|
||||
|
||||
Generate form component:
|
||||
- Import cache store
|
||||
- Form fields for model
|
||||
- Handle offline/online states
|
||||
- Submit handler with validation
|
||||
|
||||
#### List Page: `src/routes/{{MODEL_NAME}}/list/+page.svelte`
|
||||
|
||||
Generate list component:
|
||||
- Display records in table/card format
|
||||
- Search/filter functionality
|
||||
- Delete actions
|
||||
- Sync status
|
||||
|
||||
### Step 4: Update Navigation
|
||||
|
||||
Update navigation in main layout or existing pages:
|
||||
|
||||
```svelte
|
||||
<nav>
|
||||
<!-- Existing links -->
|
||||
<a href="/{{MODEL_NAME}}">{{MODEL_DISPLAY_NAME}}s</a>
|
||||
</nav>
|
||||
```
|
||||
|
||||
### Step 5: Update Environment Variables
|
||||
|
||||
Add to `.env.example` (if needed):
|
||||
```env
|
||||
# {{MODEL_DISPLAY_NAME}} Model
|
||||
ODOO_{{MODEL_NAME|uppercase}}_MODEL=x_{{MODEL_NAME}}
|
||||
```
|
||||
|
||||
## Post-Generation Instructions
|
||||
|
||||
Provide user with:
|
||||
|
||||
```
|
||||
✅ {{MODEL_DISPLAY_NAME}} integration added successfully!
|
||||
|
||||
📋 Next Steps:
|
||||
|
||||
1. Verify Odoo Model Setup:
|
||||
- Model name: x_{{MODEL_NAME}}
|
||||
- Add custom fields with x_studio_ prefix in Odoo Studio
|
||||
|
||||
2. Update Cache Store:
|
||||
- Edit src/lib/stores/{{MODEL_NAME}}Cache.js
|
||||
- Add all model fields to the 'fields' array
|
||||
|
||||
3. Customize UI:
|
||||
- Edit src/routes/{{MODEL_NAME}}/+page.svelte for form
|
||||
- Edit src/routes/{{MODEL_NAME}}/list/+page.svelte for list view
|
||||
- Add model-specific fields and validation
|
||||
|
||||
4. Test Integration:
|
||||
npm run dev
|
||||
- Navigate to /{{MODEL_NAME}}
|
||||
- Test create, read, update, delete operations
|
||||
- Verify offline functionality
|
||||
|
||||
📚 Model-Specific Files Created:
|
||||
- src/lib/stores/{{MODEL_NAME}}Cache.js - Cache and sync logic
|
||||
- src/routes/{{MODEL_NAME}}/+page.svelte - Add form
|
||||
- src/routes/{{MODEL_NAME}}/list/+page.svelte - List view
|
||||
|
||||
🔗 Access:
|
||||
- Add: http://localhost:5173/{{MODEL_NAME}}
|
||||
- List: http://localhost:5173/{{MODEL_NAME}}/list
|
||||
```
|
||||
|
||||
## Framework-Specific Notes
|
||||
|
||||
### SvelteKit
|
||||
- Use Svelte 5 syntax with `$state`, `$derived`, `$effect`
|
||||
- Cache stores use Svelte stores pattern
|
||||
- Routes in `src/routes/`
|
||||
|
||||
### React
|
||||
- Use React hooks (useState, useEffect)
|
||||
- Context API for cache
|
||||
- Routes configuration depends on router (React Router, etc.)
|
||||
|
||||
### Vue
|
||||
- Use Vue 3 Composition API
|
||||
- Composables for cache logic
|
||||
- Routes in `src/pages/` or as configured
|
||||
|
||||
## Error Handling
|
||||
|
||||
If generation fails:
|
||||
- Verify project has Odoo PWA structure
|
||||
- Check for existing odoo.js file
|
||||
- Ensure proper permissions for file creation
|
||||
- Provide clear error messages
|
||||
|
||||
## Examples
|
||||
|
||||
User: "Add inventory model to track items"
|
||||
- Model name: inventory
|
||||
- Display name: Inventory Item
|
||||
- Creates: inventoryCache.js, /inventory pages, API methods
|
||||
|
||||
User: "Integrate task management"
|
||||
- Model name: task
|
||||
- Display name: Task
|
||||
- Creates: taskCache.js, /task pages, API methods
|
||||
411
skills/create-odoo-pwa/SKILL.md
Normal file
411
skills/create-odoo-pwa/SKILL.md
Normal file
@@ -0,0 +1,411 @@
|
||||
---
|
||||
name: create-odoo-pwa
|
||||
description: Generate an offline-first Progressive Web App with Odoo Studio backend integration. Use when user wants to create new Odoo-backed application, mentions "PWA with Odoo", "offline Odoo app", "Odoo Studio PWA", or similar terms. Supports SvelteKit, React, and Vue frameworks.
|
||||
allowed-tools: Read, Write, Glob, Bash
|
||||
---
|
||||
|
||||
# Create Odoo PWA Application
|
||||
|
||||
Generate a production-ready Progressive Web App with Odoo Studio backend, featuring offline-first architecture, smart caching, and automatic synchronization.
|
||||
|
||||
## Before You Start
|
||||
|
||||
This skill generates a complete PWA project following proven architectural patterns:
|
||||
- **Three-layer data flow**: Component → Cache Store → API Client → Server Route → Odoo
|
||||
- **Offline-first**: IndexedDB/localStorage with background sync
|
||||
- **Smart caching**: Incremental fetch, stale detection, optimistic updates
|
||||
- **PWA-ready**: Service workers, manifest, installable
|
||||
|
||||
## Required User Input
|
||||
|
||||
Ask the user for the following information before generating:
|
||||
|
||||
1. **Project name** (required)
|
||||
- Format: kebab-case (e.g., "inventory-tracker", "expense-manager")
|
||||
- Used for directory name and package.json
|
||||
|
||||
2. **Framework** (required)
|
||||
- Options: `sveltekit` (recommended), `react`, `vue`
|
||||
- Default: sveltekit if not specified
|
||||
|
||||
3. **Primary Odoo model** (required)
|
||||
- The main custom model name WITHOUT the `x_` prefix
|
||||
- Example: If Odoo model is `x_inventory`, user provides: `inventory`
|
||||
- Will automatically add `x_` prefix in code
|
||||
|
||||
4. **Model display name** (required)
|
||||
- Human-readable singular name (e.g., "Inventory Item", "Expense")
|
||||
|
||||
5. **Deployment target** (optional)
|
||||
- Options: `vercel`, `github-pages`, `cloudflare`, `netlify`
|
||||
- Default: vercel if not specified
|
||||
|
||||
## Generation Steps
|
||||
|
||||
### Step 1: Project Initialization
|
||||
|
||||
Create the project directory and initialize the structure:
|
||||
|
||||
```bash
|
||||
mkdir {{PROJECT_NAME}}
|
||||
cd {{PROJECT_NAME}}
|
||||
```
|
||||
|
||||
Generate the appropriate structure based on framework:
|
||||
- **SvelteKit**: Use SvelteKit 2.x structure with `src/` directory
|
||||
- **React**: Use Vite + React structure
|
||||
- **Vue**: Use Vite + Vue structure
|
||||
|
||||
### Step 2: Base Configuration Files
|
||||
|
||||
Generate these files using templates from `skills/create-odoo-pwa/templates/{{FRAMEWORK}}/base/`:
|
||||
|
||||
#### For SvelteKit:
|
||||
- `package.json` - Dependencies including @sveltejs/kit, @vite-pwa/sveltekit, @sveltejs/adapter-static
|
||||
- `svelte.config.js` - SvelteKit configuration with adapter-static
|
||||
- `vite.config.js` - Vite + PWA plugin configuration
|
||||
- `jsconfig.json` or `tsconfig.json` - Path aliases and compiler options
|
||||
|
||||
#### For React:
|
||||
- `package.json` - Dependencies including React 18, Vite, vite-plugin-pwa
|
||||
- `vite.config.js` - React + PWA plugin configuration
|
||||
- `tsconfig.json` - TypeScript configuration
|
||||
|
||||
#### For Vue:
|
||||
- `package.json` - Dependencies including Vue 3, Vite, vite-plugin-pwa
|
||||
- `vite.config.js` - Vue + PWA plugin configuration
|
||||
- `tsconfig.json` - TypeScript configuration
|
||||
|
||||
### Step 3: Environment and Git Configuration
|
||||
|
||||
Create `.env.example`:
|
||||
```env
|
||||
# Odoo Instance Configuration
|
||||
ODOO_URL=https://your-instance.odoo.com
|
||||
ODOO_DB=your-database-name
|
||||
ODOO_USERNAME=your-username
|
||||
ODOO_API_KEY=your-api-key
|
||||
|
||||
# Primary Model (use x_ prefix)
|
||||
ODOO_PRIMARY_MODEL=x_{{MODEL_NAME}}
|
||||
|
||||
# Optional: For static hosting (GitHub Pages, etc.)
|
||||
PUBLIC_API_URL=
|
||||
```
|
||||
|
||||
Create `.gitignore`:
|
||||
```
|
||||
node_modules/
|
||||
.env
|
||||
dist/
|
||||
build/
|
||||
.svelte-kit/
|
||||
.vercel/
|
||||
.DS_Store
|
||||
*.log
|
||||
```
|
||||
|
||||
### Step 4: Core Library Files
|
||||
|
||||
Generate these essential files from templates:
|
||||
|
||||
#### A. Odoo API Client (`src/lib/odoo.js`)
|
||||
Features:
|
||||
- API URL configuration (supports PUBLIC_API_URL for static hosts)
|
||||
- `callApi(action, data)` - Core API communication
|
||||
- `createRecord(model, fields)` - Create records
|
||||
- `searchRecords(model, domain, fields)` - Search/read records
|
||||
- `updateRecord(model, id, values)` - Update records
|
||||
- `deleteRecord(model, id)` - Delete records
|
||||
- `formatMany2one(id)` - Format single relation fields
|
||||
- `formatMany2many(ids)` - Format multi-relation fields
|
||||
- Model-specific convenience methods
|
||||
|
||||
#### B. IndexedDB Manager (`src/lib/db.js`)
|
||||
Features:
|
||||
- Database initialization with versioning
|
||||
- Store definitions for master data (partners, categories, config)
|
||||
- CRUD operations: `add()`, `get()`, `getAll()`, `update()`, `remove()`
|
||||
- Transaction helpers
|
||||
- Error handling
|
||||
|
||||
#### C. Smart Cache Store (`src/lib/stores/{{MODEL_NAME}}Cache.js`)
|
||||
Features:
|
||||
- Framework-specific store pattern (Svelte store/React context/Vue composable)
|
||||
- Dual storage strategy (localStorage for metadata, IndexedDB for master data)
|
||||
- `initialize()` - Load cache and start background sync
|
||||
- `sync(forceFullRefresh)` - Incremental sync with Odoo
|
||||
- `forceRefresh()` - Clear cache and full sync
|
||||
- Partner name resolution with caching
|
||||
- Optimistic UI updates
|
||||
- Stale detection (5-minute cache validity)
|
||||
- Background sync (3-minute intervals)
|
||||
- Derived stores for UI states
|
||||
|
||||
#### D. Utility Functions (`src/lib/{{MODEL_NAME}}Utils.js`)
|
||||
Features:
|
||||
- Business logic calculations
|
||||
- Data normalization helpers
|
||||
- Field formatters
|
||||
|
||||
### Step 5: Server-Side API Proxy
|
||||
|
||||
#### For SvelteKit: `src/routes/api/odoo/+server.js`
|
||||
Features:
|
||||
- JSON-RPC client for Odoo
|
||||
- UID caching to reduce auth calls
|
||||
- `execute(model, method, args, kwargs)` helper
|
||||
- POST request handler with actions:
|
||||
- `create` - Create new record
|
||||
- `search` - Search and read records
|
||||
- `search_model` - Search any Odoo model
|
||||
- `update` - Update existing record
|
||||
- `delete` - Delete record
|
||||
- Error handling with descriptive messages
|
||||
- Environment variable access
|
||||
|
||||
#### For React/Vue: `src/api/odoo.js` (server endpoint)
|
||||
Similar functionality adapted to framework conventions
|
||||
|
||||
### Step 6: UI Components and Routes
|
||||
|
||||
Generate starter components and pages:
|
||||
|
||||
#### SvelteKit Routes:
|
||||
- `src/routes/+layout.svelte` - Root layout with navigation
|
||||
- `src/routes/+layout.js` - SSR/CSR configuration (ssr: false, csr: true)
|
||||
- `src/routes/+page.svelte` - Main form for creating records
|
||||
- `src/routes/list/+page.svelte` - List/table view with filtering
|
||||
- `src/app.html` - HTML template with PWA meta tags
|
||||
|
||||
#### React/Vue Pages:
|
||||
Equivalent component structure adapted to framework conventions
|
||||
|
||||
#### Shared Components:
|
||||
- `OfflineBanner` - Shows online/offline status
|
||||
- `SyncStatus` - Displays sync state and last sync time
|
||||
- `LoadingSpinner` - Loading indicator
|
||||
- Form components with offline support
|
||||
|
||||
### Step 7: PWA Configuration
|
||||
|
||||
Generate PWA files:
|
||||
|
||||
#### `static/manifest.json` (or `public/manifest.json`):
|
||||
```json
|
||||
{
|
||||
"name": "{{PROJECT_NAME}}",
|
||||
"short_name": "{{PROJECT_NAME}}",
|
||||
"description": "{{MODEL_DISPLAY_NAME}} management app",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#667eea",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Configure service worker in `vite.config.js`:
|
||||
- Auto-update strategy
|
||||
- Cache all static assets
|
||||
- Offline support
|
||||
|
||||
### Step 8: Deployment Configuration
|
||||
|
||||
Generate deployment files based on target:
|
||||
|
||||
#### Vercel (`vercel.json`):
|
||||
```json
|
||||
{
|
||||
"buildCommand": "npm run build",
|
||||
"outputDirectory": "build",
|
||||
"framework": "sveltekit",
|
||||
"regions": ["iad1"]
|
||||
}
|
||||
```
|
||||
|
||||
#### GitHub Pages (`.github/workflows/deploy.yml`):
|
||||
```yaml
|
||||
name: Deploy to GitHub Pages
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3
|
||||
- run: npm ci
|
||||
- run: npm run build
|
||||
- uses: actions/upload-pages-artifact@v2
|
||||
with:
|
||||
path: build
|
||||
```
|
||||
|
||||
#### Cloudflare/Netlify:
|
||||
Generate appropriate configuration files
|
||||
|
||||
### Step 9: Documentation
|
||||
|
||||
Generate comprehensive documentation:
|
||||
|
||||
#### `README.md`:
|
||||
- Project overview
|
||||
- Prerequisites (Node.js, Odoo account)
|
||||
- Installation steps
|
||||
- Odoo Studio model setup instructions
|
||||
- Development commands
|
||||
- Deployment guide
|
||||
- Architecture overview
|
||||
|
||||
#### `CLAUDE.md`:
|
||||
Complete architecture documentation following the expense-split-pwa pattern:
|
||||
- Project overview
|
||||
- Development commands
|
||||
- Environment setup
|
||||
- Architecture diagrams
|
||||
- Key architectural patterns
|
||||
- Odoo model structure
|
||||
- Important development notes
|
||||
- Common gotchas
|
||||
|
||||
#### `API.md`:
|
||||
- Odoo integration patterns
|
||||
- Available API methods
|
||||
- Field formatting examples
|
||||
- Common operations
|
||||
|
||||
### Step 10: Post-Generation Instructions
|
||||
|
||||
After generating all files, provide the user with:
|
||||
|
||||
```
|
||||
✅ Project '{{PROJECT_NAME}}' generated successfully!
|
||||
|
||||
📋 Next Steps:
|
||||
|
||||
1. Navigate to the project:
|
||||
cd {{PROJECT_NAME}}
|
||||
|
||||
2. Install dependencies:
|
||||
npm install
|
||||
|
||||
3. Configure Odoo credentials:
|
||||
cp .env.example .env
|
||||
# Edit .env with your Odoo instance details
|
||||
|
||||
4. Create Odoo Studio Model:
|
||||
- Log into your Odoo instance
|
||||
- Go to Studio
|
||||
- Create a new model named: x_{{MODEL_NAME}}
|
||||
- Add custom fields with x_studio_ prefix
|
||||
- Example fields:
|
||||
* x_name (Char) - Required
|
||||
* x_studio_description (Text)
|
||||
* x_studio_value (Float)
|
||||
* x_studio_date (Date)
|
||||
* x_studio_category (Many2one → res.partner or custom)
|
||||
|
||||
5. Start development server:
|
||||
npm run dev
|
||||
|
||||
6. Generate PWA icons:
|
||||
- Create 192x192 and 512x512 PNG icons
|
||||
- Place in static/ or public/ directory
|
||||
- Name them icon-192.png and icon-512.png
|
||||
|
||||
7. Deploy (optional):
|
||||
- Vercel: vercel
|
||||
- GitHub Pages: Push to main branch
|
||||
- Cloudflare: wrangler deploy
|
||||
- Netlify: netlify deploy
|
||||
|
||||
📚 Documentation:
|
||||
- README.md - Getting started guide
|
||||
- CLAUDE.md - Architecture documentation
|
||||
- API.md - Odoo integration patterns
|
||||
|
||||
🔗 Resources:
|
||||
- Odoo API Docs: https://www.odoo.com/documentation/
|
||||
- SvelteKit Docs: https://kit.svelte.dev/
|
||||
```
|
||||
|
||||
## Template Variables
|
||||
|
||||
When generating files, replace these placeholders:
|
||||
|
||||
- `{{PROJECT_NAME}}` - User's project name (kebab-case)
|
||||
- `{{MODEL_NAME}}` - Odoo model name without x_ prefix
|
||||
- `{{MODEL_DISPLAY_NAME}}` - Human-readable model name
|
||||
- `{{FRAMEWORK}}` - sveltekit/react/vue
|
||||
- `{{DEPLOYMENT_TARGET}}` - vercel/github-pages/cloudflare/netlify
|
||||
- `{{AUTHOR_NAME}}` - User's name (if provided)
|
||||
|
||||
## Common Patterns from expense-split-pwa
|
||||
|
||||
This skill implements proven patterns from the expense-split-pwa project:
|
||||
|
||||
1. **Smart Caching**: Load from cache immediately, sync in background if stale
|
||||
2. **Incremental Fetch**: Only fetch records with `id > lastRecordId`
|
||||
3. **Partner Resolution**: Batch-fetch and cache partner names
|
||||
4. **Dual-Phase Calculation**: Process settled/unsettled records separately
|
||||
5. **Optimistic Updates**: Update UI immediately, sync to server in background
|
||||
6. **Error Recovery**: Graceful degradation when offline
|
||||
|
||||
## Error Handling
|
||||
|
||||
If generation fails:
|
||||
- Verify all required input is provided
|
||||
- Check template files exist
|
||||
- Ensure proper permissions for file creation
|
||||
- Provide clear error messages to user
|
||||
|
||||
## Framework-Specific Notes
|
||||
|
||||
### SvelteKit
|
||||
- Use Svelte 5 runes syntax (`$state`, `$derived`, `$effect`)
|
||||
- Configure adapter-static for static deployment
|
||||
- Set `ssr: false` for client-side only apps
|
||||
- Use `$app/paths` for base path support
|
||||
|
||||
### React
|
||||
- Use React 18+ with hooks
|
||||
- Context API for global state
|
||||
- React Query for server state (optional)
|
||||
- Vite for build tooling
|
||||
|
||||
### Vue
|
||||
- Use Vue 3 Composition API
|
||||
- Pinia for state management
|
||||
- Composables for reusable logic
|
||||
- Vite for build tooling
|
||||
|
||||
## Testing the Generated Project
|
||||
|
||||
After generation, verify:
|
||||
1. `npm install` completes without errors
|
||||
2. `npm run dev` starts development server
|
||||
3. Form renders correctly
|
||||
4. Offline banner appears when disconnecting
|
||||
5. Data persists in localStorage/IndexedDB
|
||||
6. Sync works when back online
|
||||
|
||||
## Support
|
||||
|
||||
For issues or questions:
|
||||
- Check CLAUDE.md for architecture details
|
||||
- Review generated code comments
|
||||
- Consult Odoo API documentation
|
||||
- Verify environment variables are set correctly
|
||||
@@ -0,0 +1,11 @@
|
||||
# Odoo Instance Configuration (for backend server)
|
||||
ODOO_URL=https://your-instance.odoo.com
|
||||
ODOO_DB=your-database-name
|
||||
ODOO_USERNAME=your-username
|
||||
ODOO_API_KEY=your-api-key
|
||||
|
||||
# Primary Model (use x_ prefix for Odoo Studio models)
|
||||
ODOO_PRIMARY_MODEL=x_{{MODEL_NAME}}
|
||||
|
||||
# Frontend: API endpoint (if frontend and backend are separate)
|
||||
VITE_API_URL=http://localhost:3000
|
||||
@@ -0,0 +1,29 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
.env.production
|
||||
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#667eea" />
|
||||
<meta name="description" content="{{MODEL_DISPLAY_NAME}} management PWA with Odoo integration" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<title>{{PROJECT_NAME}}</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "{{PROJECT_NAME}}",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-plugin-react": "^7.34.2",
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"eslint-plugin-react-refresh": "^0.4.7",
|
||||
"vite": "^5.3.4",
|
||||
"vite-plugin-pwa": "^0.20.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { VitePWA } from 'vite-plugin-pwa';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
workbox: {
|
||||
globPatterns: ['**/*.{js,css,html,ico,png,svg,json,webp}']
|
||||
},
|
||||
manifest: {
|
||||
name: '{{PROJECT_NAME}}',
|
||||
short_name: '{{PROJECT_NAME}}',
|
||||
description: 'PWA for {{MODEL_DISPLAY_NAME}} management with Odoo integration',
|
||||
theme_color: '#667eea',
|
||||
background_color: '#ffffff',
|
||||
display: 'standalone',
|
||||
icons: [
|
||||
{
|
||||
src: '/icon-192.png',
|
||||
sizes: '192x192',
|
||||
type: 'image/png'
|
||||
},
|
||||
{
|
||||
src: '/icon-512.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png'
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': '/src'
|
||||
}
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: process.env.VITE_API_URL || 'http://localhost:3000',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
311
skills/create-odoo-pwa/templates/react/src/App.css.template
Normal file
311
skills/create-odoo-pwa/templates/react/src/App.css.template
Normal file
@@ -0,0 +1,311 @@
|
||||
:root {
|
||||
--primary-color: #667eea;
|
||||
--primary-dark: #5568d3;
|
||||
--bg-gradient-start: #667eea;
|
||||
--bg-gradient-end: #764ba2;
|
||||
--error-bg: #f8d7da;
|
||||
--error-text: #721c24;
|
||||
--success-bg: #d4edda;
|
||||
--success-text: #155724;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu,
|
||||
Cantarell, 'Helvetica Neue', sans-serif;
|
||||
background: linear-gradient(135deg, var(--bg-gradient-start) 0%, var(--bg-gradient-end) 100%);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.app {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: white;
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
font-size: 2.5em;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
color: #333;
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 30px;
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.nav a {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 12px;
|
||||
text-decoration: none;
|
||||
color: var(--primary-color);
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.nav a.active {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.offline-banner {
|
||||
background: #e3f2fd;
|
||||
color: #1565c0;
|
||||
padding: 12px 20px;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 20px;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
border: 2px solid #64b5f6;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.8; }
|
||||
}
|
||||
|
||||
.form, .list-container {
|
||||
background: white;
|
||||
padding: 24px;
|
||||
border-radius: 15px;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
transition: border-color 0.3s;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
button {
|
||||
flex: 1;
|
||||
min-width: 150px;
|
||||
padding: 15px;
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
button:hover:not(:disabled) {
|
||||
background: var(--primary-dark);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 5px 20px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.refresh-btn {
|
||||
background: #f0f0f0;
|
||||
color: var(--primary-color);
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
|
||||
.refresh-btn:hover:not(:disabled) {
|
||||
background: #e0e0e0;
|
||||
color: var(--primary-dark);
|
||||
}
|
||||
|
||||
.message {
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 15px;
|
||||
background: var(--success-bg);
|
||||
color: var(--success-text);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.message.error {
|
||||
background: var(--error-bg);
|
||||
color: var(--error-text);
|
||||
}
|
||||
|
||||
.list-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
padding: 10px;
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.refresh-btn-small {
|
||||
padding: 10px 15px;
|
||||
background: #f0f0f0;
|
||||
color: var(--primary-color);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
min-width: auto;
|
||||
flex: 0;
|
||||
}
|
||||
|
||||
.refresh-btn-small:hover:not(:disabled) {
|
||||
background: #e0e0e0;
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
|
||||
.loading, .empty {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #666;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.record-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.record-card {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 10px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.record-card:hover {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
.record-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.record-content h3 {
|
||||
margin: 0 0 8px 0;
|
||||
color: #333;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.record-meta {
|
||||
margin: 0;
|
||||
color: #666;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.record-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
padding: 8px 12px;
|
||||
background: #fff5f5;
|
||||
color: #e53e3e;
|
||||
border: 1px solid #feb2b2;
|
||||
border-radius: 6px;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
min-width: auto;
|
||||
flex: 0;
|
||||
}
|
||||
|
||||
.delete-btn:hover {
|
||||
background: #fed7d7;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.list-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
.record-card {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.record-actions {
|
||||
align-self: flex-end;
|
||||
}
|
||||
}
|
||||
41
skills/create-odoo-pwa/templates/react/src/App.jsx.template
Normal file
41
skills/create-odoo-pwa/templates/react/src/App.jsx.template
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Routes, Route, Link, useLocation } from 'react-router-dom';
|
||||
import AddPage from './pages/AddPage';
|
||||
import ListPage from './pages/ListPage';
|
||||
import OfflineBanner from './components/OfflineBanner';
|
||||
import './App.css';
|
||||
|
||||
function App() {
|
||||
const location = useLocation();
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<div className="container">
|
||||
<h1>📋 {{PROJECT_NAME}}</h1>
|
||||
|
||||
<OfflineBanner />
|
||||
|
||||
<nav className="nav">
|
||||
<Link
|
||||
to="/"
|
||||
className={location.pathname === '/' ? 'active' : ''}
|
||||
>
|
||||
Add {{MODEL_DISPLAY_NAME}}
|
||||
</Link>
|
||||
<Link
|
||||
to="/list"
|
||||
className={location.pathname === '/list' ? 'active' : ''}
|
||||
>
|
||||
View All
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
<Routes>
|
||||
<Route path="/" element={<AddPage />} />
|
||||
<Route path="/list" element={<ListPage />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
123
skills/create-odoo-pwa/templates/react/src/api/odoo.js.template
Normal file
123
skills/create-odoo-pwa/templates/react/src/api/odoo.js.template
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Odoo API Client for React
|
||||
* Communicates with backend server that proxies to Odoo
|
||||
*/
|
||||
|
||||
class OdooAPI {
|
||||
constructor() {
|
||||
this.apiUrl = import.meta.env.VITE_API_URL
|
||||
? `${import.meta.env.VITE_API_URL}/api/odoo`
|
||||
: '/api/odoo';
|
||||
}
|
||||
|
||||
/**
|
||||
* Call the server-side API
|
||||
* @param {string} action
|
||||
* @param {any} data
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
async callApi(action, data) {
|
||||
const response = await fetch(this.apiUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ action, data })
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'API Error');
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new record in specified model
|
||||
* @param {string} model - Odoo model name
|
||||
* @param {Record<string, any>} fields - Record fields
|
||||
* @returns {Promise<number>} - Created record ID
|
||||
*/
|
||||
async createRecord(model, fields) {
|
||||
const result = await this.callApi('create', { model, fields });
|
||||
return result.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search and read records from specified model
|
||||
* @param {string} model - Odoo model name
|
||||
* @param {any[]} domain - Odoo domain filter
|
||||
* @param {string[]} fields - Fields to retrieve
|
||||
* @returns {Promise<any[]>} - Array of records
|
||||
*/
|
||||
async searchRecords(model, domain = [], fields = []) {
|
||||
const result = await this.callApi('search', { model, domain, fields });
|
||||
return result.results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic search_read for any model
|
||||
* @param {string} model
|
||||
* @param {any[]} domain
|
||||
* @param {string[]} fields
|
||||
* @returns {Promise<any[]>}
|
||||
*/
|
||||
async searchModel(model, domain = [], fields = []) {
|
||||
const result = await this.callApi('search_model', { model, domain, fields });
|
||||
return result.results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch partner list (common operation)
|
||||
* @returns {Promise<Array<{id:number, display_name:string}>>}
|
||||
*/
|
||||
async fetchPartners() {
|
||||
return await this.searchModel('res.partner', [], ['id', 'display_name']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a many2one field value
|
||||
* @param {number|string|null|undefined} id
|
||||
* @returns {number|false}
|
||||
*/
|
||||
formatMany2one(id) {
|
||||
return id ? Number(id) : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a many2many field using the (6,0,[ids]) command
|
||||
* @param {Array<number|string>} ids
|
||||
* @returns {any[]}
|
||||
*/
|
||||
formatMany2many(ids) {
|
||||
if (!Array.isArray(ids) || ids.length === 0) return [];
|
||||
return [[6, 0, ids.map((i) => Number(i))]];
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a record
|
||||
* @param {string} model - Odoo model name
|
||||
* @param {number} id - Record ID
|
||||
* @param {Record<string, any>} values - Fields to update
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async updateRecord(model, id, values) {
|
||||
const result = await this.callApi('update', { model, id, values });
|
||||
return result.result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a record
|
||||
* @param {string} model - Odoo model name
|
||||
* @param {number} id - Record ID
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async deleteRecord(model, id) {
|
||||
const result = await this.callApi('delete', { model, id });
|
||||
return result.result;
|
||||
}
|
||||
}
|
||||
|
||||
export const odooClient = new OdooAPI();
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
function OfflineBanner() {
|
||||
const [isOffline, setIsOffline] = useState(!navigator.onLine);
|
||||
|
||||
useEffect(() => {
|
||||
const handleOnline = () => setIsOffline(false);
|
||||
const handleOffline = () => setIsOffline(true);
|
||||
|
||||
window.addEventListener('online', handleOnline);
|
||||
window.addEventListener('offline', handleOffline);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('online', handleOnline);
|
||||
window.removeEventListener('offline', handleOffline);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!isOffline) return null;
|
||||
|
||||
return (
|
||||
<div className="offline-banner">
|
||||
📡 Offline Mode - Data will be synced when you're back online
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default OfflineBanner;
|
||||
@@ -0,0 +1,243 @@
|
||||
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||
import { odooClient } from '../api/odoo';
|
||||
|
||||
const TaskContext = createContext();
|
||||
|
||||
// Cache configuration
|
||||
const CACHE_KEY = '{{MODEL_NAME}}_cache_v1';
|
||||
const CACHE_META_KEY = '{{MODEL_NAME}}_cache_meta_v1';
|
||||
const CACHE_DURATION_MS = 5 * 60 * 1000; // 5 minutes cache validity
|
||||
const SYNC_INTERVAL_MS = 3 * 60 * 1000; // Background sync every 3 minutes
|
||||
|
||||
// Helper functions for localStorage
|
||||
function loadFromStorage() {
|
||||
try {
|
||||
const cachedData = localStorage.getItem(CACHE_KEY);
|
||||
const meta = localStorage.getItem(CACHE_META_KEY);
|
||||
|
||||
if (cachedData && meta) {
|
||||
const records = JSON.parse(cachedData);
|
||||
const metaData = JSON.parse(meta);
|
||||
|
||||
const now = Date.now();
|
||||
const isStale = now - metaData.lastSyncTime > CACHE_DURATION_MS;
|
||||
|
||||
return { records, meta: { ...metaData, isStale } };
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to load cache from storage:', e);
|
||||
}
|
||||
|
||||
return {
|
||||
records: [],
|
||||
meta: {
|
||||
lastSyncTime: 0,
|
||||
lastRecordId: 0,
|
||||
recordCount: 0,
|
||||
isStale: true
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function saveToStorage(records, meta) {
|
||||
try {
|
||||
localStorage.setItem(CACHE_KEY, JSON.stringify(records));
|
||||
localStorage.setItem(CACHE_META_KEY, JSON.stringify({
|
||||
lastSyncTime: meta.lastSyncTime || Date.now(),
|
||||
lastRecordId: meta.lastRecordId || 0,
|
||||
recordCount: records.length,
|
||||
isStale: false
|
||||
}));
|
||||
} catch (e) {
|
||||
console.warn('Failed to save cache to storage:', e);
|
||||
}
|
||||
}
|
||||
|
||||
export function TaskProvider({ children }) {
|
||||
const [records, setRecords] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [meta, setMeta] = useState({
|
||||
lastSyncTime: 0,
|
||||
lastRecordId: 0,
|
||||
recordCount: 0,
|
||||
isStale: true
|
||||
});
|
||||
|
||||
// Sync function - fetches new data from server
|
||||
const sync = useCallback(async (forceFullRefresh = false) => {
|
||||
setSyncing(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const fields = ['id', 'x_name'];
|
||||
let domain = [];
|
||||
let fetchedRecords = [];
|
||||
|
||||
if (!forceFullRefresh && meta.lastRecordId > 0) {
|
||||
// Incremental fetch
|
||||
domain = [['id', '>', meta.lastRecordId]];
|
||||
try {
|
||||
fetchedRecords = await odooClient.searchRecords('x_{{MODEL_NAME}}', domain, fields);
|
||||
} catch (err) {
|
||||
console.warn('Incremental fetch failed, falling back to full fetch:', err);
|
||||
forceFullRefresh = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (forceFullRefresh || meta.lastRecordId === 0) {
|
||||
// Full refresh
|
||||
fetchedRecords = await odooClient.searchRecords('x_{{MODEL_NAME}}', [], fields);
|
||||
}
|
||||
|
||||
// Merge or replace records
|
||||
let mergedRecords;
|
||||
if (forceFullRefresh || meta.lastRecordId === 0) {
|
||||
mergedRecords = fetchedRecords;
|
||||
} else {
|
||||
const existingIds = new Set(records.map(r => r.id));
|
||||
const newRecords = fetchedRecords.filter(r => !existingIds.has(r.id));
|
||||
mergedRecords = [...records, ...newRecords];
|
||||
}
|
||||
|
||||
// Sort by ID
|
||||
mergedRecords.sort((a, b) => a.id - b.id);
|
||||
|
||||
// Calculate new metadata
|
||||
const lastRecordId = mergedRecords.length > 0
|
||||
? Math.max(...mergedRecords.map(r => r.id))
|
||||
: 0;
|
||||
|
||||
const newMeta = {
|
||||
lastSyncTime: Date.now(),
|
||||
lastRecordId,
|
||||
recordCount: mergedRecords.length,
|
||||
isStale: false
|
||||
};
|
||||
|
||||
// Save to storage
|
||||
saveToStorage(mergedRecords, newMeta);
|
||||
|
||||
// Update state
|
||||
setRecords(mergedRecords);
|
||||
setMeta(newMeta);
|
||||
} catch (error) {
|
||||
console.error('Sync failed:', error);
|
||||
setError(error.message || 'Failed to sync data');
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
}, [meta.lastRecordId, records]);
|
||||
|
||||
// Initialize - load cache and start background sync
|
||||
useEffect(() => {
|
||||
const cachedState = loadFromStorage();
|
||||
|
||||
if (cachedState.records.length > 0) {
|
||||
setRecords(cachedState.records);
|
||||
setMeta(cachedState.meta);
|
||||
|
||||
// Sync in background if stale
|
||||
if (cachedState.meta.isStale) {
|
||||
sync();
|
||||
}
|
||||
} else {
|
||||
setLoading(true);
|
||||
sync(true).finally(() => setLoading(false));
|
||||
}
|
||||
|
||||
// Set up periodic background sync
|
||||
const syncInterval = setInterval(() => {
|
||||
sync();
|
||||
}, SYNC_INTERVAL_MS);
|
||||
|
||||
return () => clearInterval(syncInterval);
|
||||
}, []);
|
||||
|
||||
// Force refresh
|
||||
const forceRefresh = useCallback(async () => {
|
||||
await sync(true);
|
||||
}, [sync]);
|
||||
|
||||
// Create record
|
||||
const createRecord = useCallback(async (fields) => {
|
||||
try {
|
||||
const id = await odooClient.createRecord('x_{{MODEL_NAME}}', fields);
|
||||
|
||||
// Optimistically add to cache
|
||||
const newRecord = { id, ...fields };
|
||||
setRecords(prev => [...prev, newRecord]);
|
||||
|
||||
// Sync to get full record
|
||||
await sync();
|
||||
|
||||
return id;
|
||||
} catch (error) {
|
||||
console.error('Failed to create record:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [sync]);
|
||||
|
||||
// Update record
|
||||
const updateRecord = useCallback(async (id, values) => {
|
||||
try {
|
||||
await odooClient.updateRecord('x_{{MODEL_NAME}}', id, values);
|
||||
|
||||
// Optimistically update cache
|
||||
setRecords(prev =>
|
||||
prev.map(r => r.id === id ? { ...r, ...values } : r)
|
||||
);
|
||||
|
||||
// Sync to get full updated record
|
||||
await sync();
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to update record:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [sync]);
|
||||
|
||||
// Delete record
|
||||
const deleteRecord = useCallback(async (id) => {
|
||||
try {
|
||||
await odooClient.deleteRecord('x_{{MODEL_NAME}}', id);
|
||||
|
||||
// Optimistically remove from cache
|
||||
setRecords(prev => prev.filter(r => r.id !== id));
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to delete record:', error);
|
||||
throw error;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const value = {
|
||||
records,
|
||||
loading,
|
||||
syncing,
|
||||
error,
|
||||
meta,
|
||||
sync,
|
||||
forceRefresh,
|
||||
createRecord,
|
||||
updateRecord,
|
||||
deleteRecord
|
||||
};
|
||||
|
||||
return (
|
||||
<TaskContext.Provider value={value}>
|
||||
{children}
|
||||
</TaskContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTask() {
|
||||
const context = useContext(TaskContext);
|
||||
if (!context) {
|
||||
throw new Error('useTask must be used within TaskProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
:root {
|
||||
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
place-items: center;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
#root {
|
||||
width: 100%;
|
||||
}
|
||||
16
skills/create-odoo-pwa/templates/react/src/main.jsx.template
Normal file
16
skills/create-odoo-pwa/templates/react/src/main.jsx.template
Normal file
@@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import App from './App';
|
||||
import { TaskProvider } from './context/TaskContext';
|
||||
import './index.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<TaskProvider>
|
||||
<App />
|
||||
</TaskProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useState } from 'react';
|
||||
import { useTask } from '../context/TaskContext';
|
||||
|
||||
function AddPage() {
|
||||
const [name, setName] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
const { createRecord, forceRefresh } = useTask();
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!name.trim()) {
|
||||
setMessage('⚠️ Please fill in the name field');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setMessage('');
|
||||
|
||||
try {
|
||||
const payload = { x_name: name };
|
||||
await createRecord(payload);
|
||||
|
||||
if (navigator.onLine) {
|
||||
setMessage('✅ {{MODEL_DISPLAY_NAME}} added successfully!');
|
||||
} else {
|
||||
setMessage('✅ {{MODEL_DISPLAY_NAME}} saved locally! Will sync when online.');
|
||||
}
|
||||
|
||||
// Reset form
|
||||
setName('');
|
||||
} catch (error) {
|
||||
setMessage(`❌ Error: ${error.message}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="form">
|
||||
<div className="form-group">
|
||||
<label htmlFor="name">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Enter {{MODEL_DISPLAY_NAME}} name"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className={`message ${message.includes('❌') ? 'error' : ''}`}>
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="button-group">
|
||||
<button type="submit" disabled={loading}>
|
||||
{loading ? '⏳ Adding...' : '➕ Add {{MODEL_DISPLAY_NAME}}'}
|
||||
</button>
|
||||
{navigator.onLine && (
|
||||
<button
|
||||
type="button"
|
||||
className="refresh-btn"
|
||||
onClick={forceRefresh}
|
||||
>
|
||||
🔄 Refresh Data
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export default AddPage;
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useState } from 'react';
|
||||
import { useTask } from '../context/TaskContext';
|
||||
|
||||
function ListPage() {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const { records, loading, syncing, forceRefresh, deleteRecord } = useTask();
|
||||
|
||||
const filteredRecords = records.filter(record =>
|
||||
record.x_name?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
if (!confirm('Are you sure you want to delete this {{MODEL_DISPLAY_NAME}}?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteRecord(id);
|
||||
} catch (error) {
|
||||
alert(`Failed to delete: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="list-container">
|
||||
<div className="list-header">
|
||||
<h2>All {{MODEL_DISPLAY_NAME}}s</h2>
|
||||
<div className="header-actions">
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="search-input"
|
||||
/>
|
||||
<button
|
||||
className="refresh-btn-small"
|
||||
onClick={forceRefresh}
|
||||
disabled={syncing}
|
||||
>
|
||||
{syncing ? '⏳' : '🔄'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="loading">Loading...</div>
|
||||
) : filteredRecords.length === 0 ? (
|
||||
<div className="empty">
|
||||
{searchTerm ? 'No matching records found' : 'No {{MODEL_DISPLAY_NAME}}s yet. Add your first one!'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="record-list">
|
||||
{filteredRecords.map(record => (
|
||||
<div key={record.id} className="record-card">
|
||||
<div className="record-content">
|
||||
<h3>{record.x_name}</h3>
|
||||
<p className="record-meta">ID: {record.id}</p>
|
||||
</div>
|
||||
<div className="record-actions">
|
||||
<button
|
||||
className="delete-btn"
|
||||
onClick={() => handleDelete(record.id)}
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ListPage;
|
||||
@@ -0,0 +1,12 @@
|
||||
# Odoo Instance Configuration
|
||||
ODOO_URL=https://your-instance.odoo.com
|
||||
ODOO_DB=your-database-name
|
||||
ODOO_USERNAME=your-username
|
||||
ODOO_API_KEY=your-api-key
|
||||
|
||||
# Primary Model (use x_ prefix for Odoo Studio models)
|
||||
ODOO_PRIMARY_MODEL=x_{{MODEL_NAME}}
|
||||
|
||||
# Optional: For static hosting (GitHub Pages, Cloudflare Pages, etc.)
|
||||
# Set this to the full URL of your API server if frontend and backend are on different domains
|
||||
PUBLIC_API_URL=
|
||||
@@ -0,0 +1,15 @@
|
||||
node_modules/
|
||||
.env
|
||||
.env.local
|
||||
dist/
|
||||
build/
|
||||
.svelte-kit/
|
||||
.vercel/
|
||||
.netlify/
|
||||
.DS_Store
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": false,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "{{PROJECT_NAME}}",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json --watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^6.1.0",
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@sveltejs/kit": "^2.43.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.0",
|
||||
"@vite-pwa/sveltekit": "^1.0.1",
|
||||
"svelte": "^5.39.5",
|
||||
"svelte-check": "^4.3.2",
|
||||
"typescript": "^5.9.2",
|
||||
"vite": "^7.1.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"vite-plugin-pwa": "^1.1.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import adapter from '@sveltejs/adapter-static';
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
preprocess: vitePreprocess(),
|
||||
|
||||
kit: {
|
||||
adapter: adapter({
|
||||
pages: 'build',
|
||||
assets: 'build',
|
||||
fallback: undefined,
|
||||
precompress: false,
|
||||
strict: true
|
||||
}),
|
||||
// Configure base path for GitHub Pages deployment
|
||||
paths: {
|
||||
base: process.argv.includes('dev') ? '' : process.env.PUBLIC_BASE_PATH || ''
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,40 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
import { VitePWA } from 'vite-plugin-pwa';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
sveltekit(),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
workbox: {
|
||||
globPatterns: ['**/*.{js,css,html,ico,png,svg,json,webp}']
|
||||
},
|
||||
manifest: {
|
||||
id: '/',
|
||||
name: '{{PROJECT_NAME}}',
|
||||
short_name: '{{PROJECT_NAME}}',
|
||||
description: 'PWA for {{MODEL_DISPLAY_NAME}} management with Odoo integration',
|
||||
start_url: '/',
|
||||
scope: '/',
|
||||
display: 'standalone',
|
||||
background_color: '#ffffff',
|
||||
theme_color: '#667eea',
|
||||
icons: [
|
||||
{
|
||||
src: '/icon-192.png',
|
||||
sizes: '192x192',
|
||||
type: 'image/png',
|
||||
purpose: 'any maskable'
|
||||
},
|
||||
{
|
||||
src: '/icon-512.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png',
|
||||
purpose: 'any maskable'
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
]
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
name: Deploy to GitHub Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: "pages"
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
cache: 'npm'
|
||||
- run: npm ci
|
||||
- run: npm run build
|
||||
env:
|
||||
PUBLIC_BASE_PATH: /${{ github.event.repository.name }}
|
||||
ODOO_URL: ${{ secrets.ODOO_URL }}
|
||||
ODOO_DB: ${{ secrets.ODOO_DB }}
|
||||
ODOO_USERNAME: ${{ secrets.ODOO_USERNAME }}
|
||||
ODOO_API_KEY: ${{ secrets.ODOO_API_KEY }}
|
||||
ODOO_PRIMARY_MODEL: ${{ secrets.ODOO_PRIMARY_MODEL }}
|
||||
- uses: actions/upload-pages-artifact@v2
|
||||
with:
|
||||
path: build
|
||||
|
||||
deploy:
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v2
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"buildCommand": "npm run build",
|
||||
"outputDirectory": "build",
|
||||
"framework": "sveltekit",
|
||||
"regions": ["iad1"]
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code when working with this {{PROJECT_NAME}} codebase.
|
||||
|
||||
## Project Overview
|
||||
|
||||
**{{PROJECT_NAME}}** - An offline-first Progressive Web App for {{MODEL_DISPLAY_NAME}} management, built with SvelteKit frontend and Odoo Studio backend.
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Development
|
||||
```bash
|
||||
npm run dev # Start dev server at http://localhost:5173
|
||||
```
|
||||
|
||||
### Building
|
||||
```bash
|
||||
npm run build # Production build (outputs to /build)
|
||||
npm run preview # Preview production build locally
|
||||
```
|
||||
|
||||
### Type Checking
|
||||
```bash
|
||||
npm run check # Run svelte-check for type errors
|
||||
npm run check:watch # Watch mode for type checking
|
||||
```
|
||||
|
||||
## Environment Setup
|
||||
|
||||
Required environment variables in `.env`:
|
||||
```env
|
||||
ODOO_URL=https://your-instance.odoo.com
|
||||
ODOO_DB=your-database-name
|
||||
ODOO_USERNAME=your-username
|
||||
ODOO_API_KEY=your-api-key
|
||||
ODOO_PRIMARY_MODEL=x_{{MODEL_NAME}}
|
||||
```
|
||||
|
||||
**Important**: The app uses API keys (not passwords) for Odoo authentication. These are server-side only and never exposed to the client.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Three-Layer Data Flow
|
||||
|
||||
```
|
||||
Frontend Component (Svelte)
|
||||
↓
|
||||
Cache Store (Svelte Store)
|
||||
↓
|
||||
Odoo API Client (src/lib/odoo.js)
|
||||
↓
|
||||
Server Route (src/routes/api/odoo/+server.js)
|
||||
↓
|
||||
Odoo JSON-RPC Backend
|
||||
```
|
||||
|
||||
Data also persists to localStorage for offline access.
|
||||
|
||||
### Key Architectural Patterns
|
||||
|
||||
#### 1. **Smart Caching Layer** (`src/lib/stores/cache.js`)
|
||||
|
||||
The centerpiece of the frontend architecture. This Svelte store provides:
|
||||
|
||||
- **Immediate data availability**: Shows cached data from localStorage instantly on page load
|
||||
- **Background sync**: Automatically syncs with Odoo every 3 minutes if cache is stale (>5 minutes old)
|
||||
- **Incremental fetching**: Only fetches records with `id > lastRecordId` to minimize API calls
|
||||
- **Optimistic updates**: UI updates immediately, syncs to server in background
|
||||
|
||||
**Key functions**:
|
||||
- `initialize()` - Call in `onMount()`, loads cache and starts background sync
|
||||
- `sync()` - Incremental sync with Odoo
|
||||
- `forceRefresh()` - Clears cache and does full sync
|
||||
- `createRecord()`, `updateRecord()`, `deleteRecord()` - CRUD operations with optimistic updates
|
||||
|
||||
#### 2. **Server-Side API Proxy** (`src/routes/api/odoo/+server.js`)
|
||||
|
||||
SvelteKit server route that acts as a JSON-RPC proxy to Odoo. This pattern:
|
||||
|
||||
- Keeps credentials server-side (never exposed to client)
|
||||
- Caches Odoo UID to reduce authentication calls
|
||||
- Provides a simple `{ action, data }` interface for the frontend
|
||||
|
||||
**Supported actions**: `create`, `search`, `search_model`, `update`, `delete`
|
||||
|
||||
#### 3. **Odoo Field Formatting**
|
||||
|
||||
Odoo uses specific formats for relational fields:
|
||||
|
||||
- **Many2One (single relation)**: Send as integer ID, receive as `[id, "display_name"]` tuple
|
||||
- **Many2Many (multiple relations)**: Send as `[[6, 0, [id1, id2, ...]]]`, receive as array of tuples
|
||||
|
||||
Helper functions in `src/lib/odoo.js`:
|
||||
- `formatMany2one(id)` - Converts ID to integer or `false`
|
||||
- `formatMany2many(ids)` - Wraps IDs in Odoo command format `[[6, 0, [...]]]`
|
||||
|
||||
#### 4. **Offline-First Strategy**
|
||||
|
||||
Two-phase data loading:
|
||||
|
||||
1. **Immediate Load**: Show cached data from localStorage
|
||||
2. **Background Sync**: Fetch new data if cache is stale
|
||||
|
||||
```javascript
|
||||
// Phase 1: Load from cache immediately
|
||||
const cachedData = loadFromStorage();
|
||||
updateUI(cachedData);
|
||||
|
||||
// Phase 2: Background sync if stale
|
||||
if (cachedData.isStale) {
|
||||
syncInBackground();
|
||||
}
|
||||
```
|
||||
|
||||
### SvelteKit Configuration
|
||||
|
||||
- **Rendering**: `ssr: false`, `csr: true`, `prerender: true` in `src/routes/+layout.js`
|
||||
- **Adapter**: `adapter-static` configured for static output to `/build` directory
|
||||
- **Base path**: Configurable via `PUBLIC_BASE_PATH` env var (for GitHub Pages deployment)
|
||||
|
||||
### PWA Features
|
||||
|
||||
Configured in `vite.config.js`:
|
||||
- **Service Worker**: Auto-generated with Workbox, caches all static assets
|
||||
- **Auto-update**: New versions automatically activate
|
||||
- **Manifest**: Configured for standalone mode, installable on mobile
|
||||
- **Icons**: 192x192 and 512x512 PNG icons in `/static`
|
||||
|
||||
## Odoo Model Structure
|
||||
|
||||
### Main Model: `x_{{MODEL_NAME}}`
|
||||
|
||||
| Field | Type | Purpose |
|
||||
|-------|------|---------|
|
||||
| `x_name` | Char | Record name |
|
||||
| Add your custom fields with x_studio_ prefix |
|
||||
|
||||
## Important Development Notes
|
||||
|
||||
### Working with the Cache
|
||||
|
||||
When modifying record-related features:
|
||||
|
||||
1. **Always call `{{MODEL_NAME}}Cache.sync()` after mutations** (create/update/delete)
|
||||
2. **Initialize the cache in page components**: `onMount(() => {{MODEL_NAME}}Cache.initialize())`
|
||||
3. **Clean up on unmount**: `onDestroy(() => {{MODEL_NAME}}Cache.destroy())`
|
||||
4. **Cache invalidation**: Increment cache version in `cache.js` if store schema changes
|
||||
|
||||
### Odoo API Patterns
|
||||
|
||||
When adding new Odoo operations:
|
||||
|
||||
1. **Frontend**: Add method to `src/lib/odoo.js` (calls `/api/odoo` endpoint)
|
||||
2. **Backend**: Add new action case in `src/routes/api/odoo/+server.js`
|
||||
3. **Use `execute()` helper** for model operations (wraps authentication)
|
||||
|
||||
Example:
|
||||
```javascript
|
||||
// Frontend (odoo.js)
|
||||
async customOperation(id) {
|
||||
return this.callApi('custom', { id });
|
||||
}
|
||||
|
||||
// Backend (+server.js)
|
||||
case 'custom':
|
||||
const result = await execute('x_{{MODEL_NAME}}', 'write', [[data.id], { custom_field: true }]);
|
||||
return json({ success: true, result });
|
||||
```
|
||||
|
||||
### PWA Manifest Updates
|
||||
|
||||
When changing app name, icons, or theme:
|
||||
|
||||
1. Update `vite.config.js` manifest section
|
||||
2. Update `/static/manifest.json`
|
||||
3. Replace `/static/icon-192.png` and `/static/icon-512.png`
|
||||
4. Run `npm run build` to regenerate service worker
|
||||
|
||||
### Deployment
|
||||
|
||||
**Vercel** (primary):
|
||||
- Automatically deploys from `main` branch
|
||||
- Set environment variables in Vercel dashboard
|
||||
- Outputs to `/build` directory (configured in `vercel.json`)
|
||||
|
||||
**GitHub Pages**:
|
||||
- Set `PUBLIC_BASE_PATH=/repo-name` in GitHub Actions secrets
|
||||
- Configure GitHub Pages source as "GitHub Actions"
|
||||
- Workflow auto-deploys on push to `main`
|
||||
|
||||
## File Structure Reference
|
||||
|
||||
```
|
||||
src/
|
||||
├── lib/
|
||||
│ ├── stores/
|
||||
│ │ └── cache.js # Core caching & sync logic
|
||||
│ ├── odoo.js # Frontend API client
|
||||
│ ├── db.js # IndexedDB manager
|
||||
│ └── utils.js # Utility functions
|
||||
├── routes/
|
||||
│ ├── +layout.js # Root layout config (SSR/CSR settings)
|
||||
│ ├── +layout.svelte # Root layout component
|
||||
│ ├── +page.svelte # Add record form
|
||||
│ ├── list/+page.svelte # List all records
|
||||
│ └── api/odoo/+server.js # Odoo JSON-RPC proxy endpoint
|
||||
└── app.html # HTML template with PWA meta tags
|
||||
```
|
||||
|
||||
## Common Gotchas
|
||||
|
||||
1. **Odoo field naming**: All custom fields use `x_studio_` prefix (Odoo Studio convention)
|
||||
2. **Partner name format**: Odoo returns `[id, "name"]` tuples, must extract display name for UI
|
||||
3. **localStorage limits**: Browser typically allows 5-10MB, sufficient for hundreds of records
|
||||
4. **Service worker caching**: After PWA updates, users may need to close all tabs and reopen
|
||||
5. **Base path in production**: GitHub Pages deployments require `PUBLIC_BASE_PATH` env var set
|
||||
6. **API authentication**: Use API keys, not passwords. Generate in Odoo user settings.
|
||||
|
||||
## Adding New Features
|
||||
|
||||
### Add a New Field
|
||||
|
||||
1. Add field in Odoo Studio with `x_studio_` prefix
|
||||
2. Update `fields` array in `src/lib/stores/cache.js`
|
||||
3. Add form input in `src/routes/+page.svelte`
|
||||
4. Display field in `src/routes/list/+page.svelte`
|
||||
|
||||
### Add a New Page
|
||||
|
||||
1. Create `src/routes/new-page/+page.svelte`
|
||||
2. Add navigation link in layout or other pages
|
||||
3. Import and use cache store if accessing data
|
||||
|
||||
### Add Partner/Relation Field
|
||||
|
||||
1. Add Many2one or Many2many field in Odoo
|
||||
2. Use `odooClient.formatMany2one()` or `formatMany2many()` when saving
|
||||
3. Use `odooClient.fetchPartners()` to load options
|
||||
4. Display resolved names in UI
|
||||
|
||||
---
|
||||
|
||||
**Generated with Odoo PWA Generator**
|
||||
@@ -0,0 +1,225 @@
|
||||
# {{PROJECT_NAME}}
|
||||
|
||||
An offline-first Progressive Web App for {{MODEL_DISPLAY_NAME}} management with Odoo Studio backend integration.
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- 📱 **Progressive Web App** - Installable on mobile and desktop
|
||||
- 🔌 **Offline-first** - Works without internet connection
|
||||
- 🔄 **Auto-sync** - Background synchronization with Odoo
|
||||
- 💾 **Smart caching** - localStorage + IndexedDB for optimal performance
|
||||
- 🎨 **Responsive UI** - Works on all devices
|
||||
- ⚡ **Fast** - Instant UI updates with optimistic rendering
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 18+ installed
|
||||
- Odoo Studio account with API access
|
||||
- Custom Odoo model created: `x_{{MODEL_NAME}}`
|
||||
|
||||
### Installation
|
||||
|
||||
1. Clone this repository:
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd {{PROJECT_NAME}}
|
||||
```
|
||||
|
||||
2. Install dependencies:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. Configure environment:
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
4. Edit `.env` with your Odoo credentials:
|
||||
```env
|
||||
ODOO_URL=https://your-instance.odoo.com
|
||||
ODOO_DB=your-database-name
|
||||
ODOO_USERNAME=your-username
|
||||
ODOO_API_KEY=your-api-key
|
||||
ODOO_PRIMARY_MODEL=x_{{MODEL_NAME}}
|
||||
```
|
||||
|
||||
5. Start development server:
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
6. Open [http://localhost:5173](http://localhost:5173)
|
||||
|
||||
## 🔧 Odoo Studio Setup
|
||||
|
||||
### Create Custom Model
|
||||
|
||||
1. Log into your Odoo instance
|
||||
2. Navigate to **Studio**
|
||||
3. Create a new model: `x_{{MODEL_NAME}}`
|
||||
4. Add these recommended fields:
|
||||
- `x_name` (Char) - Required - Record name
|
||||
- `x_studio_description` (Text) - Description
|
||||
- `x_studio_date` (Date) - Date field
|
||||
- `x_studio_value` (Float) - Numeric value
|
||||
- Add more custom fields as needed with `x_studio_` prefix
|
||||
|
||||
### Get API Credentials
|
||||
|
||||
1. Go to **Settings** → **Users & Companies** → **Users**
|
||||
2. Select your user
|
||||
3. Click **Generate API Key**
|
||||
4. Copy the key and add to `.env` file
|
||||
|
||||
## 📦 Build & Deployment
|
||||
|
||||
### Build for Production
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Deploy to Vercel
|
||||
|
||||
```bash
|
||||
npm install -g vercel
|
||||
vercel
|
||||
```
|
||||
|
||||
Set environment variables in Vercel dashboard:
|
||||
- `ODOO_URL`
|
||||
- `ODOO_DB`
|
||||
- `ODOO_USERNAME`
|
||||
- `ODOO_API_KEY`
|
||||
- `ODOO_PRIMARY_MODEL`
|
||||
|
||||
### Deploy to GitHub Pages
|
||||
|
||||
1. Add secrets to GitHub repository:
|
||||
- `ODOO_URL`, `ODOO_DB`, `ODOO_USERNAME`, `ODOO_API_KEY`, `ODOO_PRIMARY_MODEL`
|
||||
|
||||
2. Enable GitHub Pages in repository settings:
|
||||
- Source: GitHub Actions
|
||||
|
||||
3. Push to main branch - auto-deploys via GitHub Actions
|
||||
|
||||
### Deploy to Cloudflare Pages
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
wrangler pages publish build
|
||||
```
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
This app follows a three-layer architecture:
|
||||
|
||||
```
|
||||
Frontend Component
|
||||
↓
|
||||
Cache Store (Svelte Store)
|
||||
↓
|
||||
Odoo API Client (Frontend)
|
||||
↓
|
||||
Server Route (/api/odoo)
|
||||
↓
|
||||
Odoo JSON-RPC
|
||||
```
|
||||
|
||||
### Key Features:
|
||||
|
||||
- **Smart Caching**: Loads from cache instantly, syncs in background
|
||||
- **Incremental Sync**: Only fetches new records since last sync
|
||||
- **Offline Queue**: Saves changes locally when offline, syncs when online
|
||||
- **PWA**: Service worker caches all assets for offline use
|
||||
|
||||
See `CLAUDE.md` for detailed architecture documentation.
|
||||
|
||||
## 🛠️ Development
|
||||
|
||||
### Available Scripts
|
||||
|
||||
- `npm run dev` - Start development server
|
||||
- `npm run build` - Build for production
|
||||
- `npm run preview` - Preview production build
|
||||
- `npm run check` - Type check
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
{{PROJECT_NAME}}/
|
||||
├── src/
|
||||
│ ├── lib/
|
||||
│ │ ├── odoo.js # Odoo API client
|
||||
│ │ ├── db.js # IndexedDB manager
|
||||
│ │ ├── utils.js # Utility functions
|
||||
│ │ └── stores/
|
||||
│ │ └── cache.js # Smart cache store
|
||||
│ ├── routes/
|
||||
│ │ ├── +layout.svelte # Root layout
|
||||
│ │ ├── +page.svelte # Add record form
|
||||
│ │ ├── list/+page.svelte # List all records
|
||||
│ │ └── api/odoo/+server.js # Server API proxy
|
||||
│ └── app.html # HTML template
|
||||
├── static/
|
||||
│ ├── manifest.json # PWA manifest
|
||||
│ ├── icon-192.png # App icon 192x192
|
||||
│ └── icon-512.png # App icon 512x512
|
||||
├── .env # Environment variables (gitignored)
|
||||
├── .env.example # Environment template
|
||||
├── svelte.config.js # SvelteKit configuration
|
||||
├── vite.config.js # Vite + PWA configuration
|
||||
└── package.json # Dependencies
|
||||
```
|
||||
|
||||
## 📝 Adding Fields
|
||||
|
||||
To add custom fields to your model:
|
||||
|
||||
1. Add field in Odoo Studio (use `x_studio_` prefix)
|
||||
2. Update `src/lib/stores/cache.js` - add field to `fields` array
|
||||
3. Update `src/routes/+page.svelte` - add form input
|
||||
4. Update `src/routes/list/+page.svelte` - display field
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### "Authentication failed"
|
||||
- Verify `ODOO_API_KEY` is correct
|
||||
- Check `ODOO_USERNAME` matches your Odoo user
|
||||
- Ensure API access is enabled in Odoo
|
||||
|
||||
### "Model not found"
|
||||
- Verify model name uses `x_` prefix: `x_{{MODEL_NAME}}`
|
||||
- Check model exists in Odoo Studio
|
||||
- Ensure model is published
|
||||
|
||||
### Offline mode not working
|
||||
- Check service worker is registered (DevTools → Application)
|
||||
- Verify PWA manifest is loaded
|
||||
- Clear browser cache and reload
|
||||
|
||||
### Data not syncing
|
||||
- Check browser console for errors
|
||||
- Verify internet connection
|
||||
- Check Odoo API is accessible
|
||||
|
||||
## 📚 Learn More
|
||||
|
||||
- [SvelteKit Documentation](https://kit.svelte.dev/)
|
||||
- [Odoo API Documentation](https://www.odoo.com/documentation/)
|
||||
- [PWA Guide](https://web.dev/progressive-web-apps/)
|
||||
|
||||
## 📄 License
|
||||
|
||||
MIT
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
Contributions welcome! Please open an issue or PR.
|
||||
|
||||
---
|
||||
|
||||
**Generated with [Odoo PWA Generator](https://github.com/jamshid/odoo-pwa-generator)** 🚀
|
||||
@@ -0,0 +1,134 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import {
|
||||
ODOO_URL,
|
||||
ODOO_DB,
|
||||
ODOO_USERNAME,
|
||||
ODOO_API_KEY
|
||||
} from '$env/static/private';
|
||||
|
||||
/** @type {number|null} */
|
||||
let cachedUid = null;
|
||||
|
||||
/**
|
||||
* Make JSON-RPC call to Odoo
|
||||
* @param {string} service
|
||||
* @param {string} method
|
||||
* @param {any[]} args
|
||||
*/
|
||||
async function callOdoo(service, method, args) {
|
||||
const response = await fetch(`${ODOO_URL}/jsonrpc`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
method: 'call',
|
||||
params: {
|
||||
service: service,
|
||||
method: method,
|
||||
args: args
|
||||
},
|
||||
id: Math.floor(Math.random() * 1000000)
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error) {
|
||||
throw new Error(data.error.data?.message || data.error.message || 'Odoo API Error');
|
||||
}
|
||||
|
||||
return data.result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate with Odoo and get UID
|
||||
*/
|
||||
async function authenticate() {
|
||||
if (cachedUid) return cachedUid;
|
||||
|
||||
const authMethod = ODOO_API_KEY;
|
||||
const uid = await callOdoo('common', 'login', [ODOO_DB, ODOO_USERNAME, authMethod]);
|
||||
|
||||
if (!uid) {
|
||||
throw new Error('Authentication failed');
|
||||
}
|
||||
|
||||
cachedUid = uid;
|
||||
return uid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a method on Odoo model
|
||||
* @param {string} model
|
||||
* @param {string} method
|
||||
* @param {any[]} args
|
||||
* @param {Record<string, any>} kwargs
|
||||
*/
|
||||
async function execute(model, method, args = [], kwargs = {}) {
|
||||
const uid = await authenticate();
|
||||
const authMethod = ODOO_API_KEY;
|
||||
|
||||
return await callOdoo('object', 'execute_kw', [
|
||||
ODOO_DB,
|
||||
uid,
|
||||
authMethod,
|
||||
model,
|
||||
method,
|
||||
args,
|
||||
kwargs
|
||||
]);
|
||||
}
|
||||
|
||||
/** @type {import('./$types').RequestHandler} */
|
||||
export async function POST({ request }) {
|
||||
try {
|
||||
const { action, data } = await request.json();
|
||||
|
||||
switch (action) {
|
||||
case 'create': {
|
||||
const { model, fields } = data;
|
||||
const id = await execute(model, 'create', [fields]);
|
||||
return json({ success: true, id });
|
||||
}
|
||||
|
||||
case 'search': {
|
||||
const { model, domain = [], fields = [] } = data;
|
||||
const results = await execute(model, 'search_read', [domain], { fields });
|
||||
return json({ success: true, results });
|
||||
}
|
||||
|
||||
// Search any model (used by frontend to load res.partner list, etc.)
|
||||
case 'search_model': {
|
||||
const { model, domain = [], fields = [] } = data;
|
||||
const results = await execute(model, 'search_read', [domain], { fields });
|
||||
return json({ success: true, results });
|
||||
}
|
||||
|
||||
case 'update': {
|
||||
const { model, id, values } = data;
|
||||
const result = await execute(model, 'write', [[id], values]);
|
||||
return json({ success: true, result });
|
||||
}
|
||||
|
||||
case 'delete': {
|
||||
const { model, id } = data;
|
||||
const result = await execute(model, 'unlink', [[id]]);
|
||||
return json({ success: true, result });
|
||||
}
|
||||
|
||||
default:
|
||||
return json({ success: false, error: 'Invalid action' }, { status: 400 });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Odoo API Error:', error);
|
||||
return json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
<script>
|
||||
import { {{MODEL_NAME}}Cache, cacheStatus } from '$lib/stores/cache';
|
||||
import { filterRecords } from '$lib/utils';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
|
||||
let searchTerm = $state('');
|
||||
let records = $derived(${{MODEL_NAME}}Cache.records);
|
||||
let filteredRecords = $derived(filterRecords(records, searchTerm, ['x_name']));
|
||||
let status = $derived($cacheStatus);
|
||||
|
||||
onMount(async () => {
|
||||
await {{MODEL_NAME}}Cache.initialize();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
{{MODEL_NAME}}Cache.destroy();
|
||||
});
|
||||
|
||||
async function handleDelete(id) {
|
||||
if (!confirm('Are you sure you want to delete this {{MODEL_DISPLAY_NAME}}?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await {{MODEL_NAME}}Cache.deleteRecord(id);
|
||||
} catch (error) {
|
||||
alert(`Failed to delete: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function handleRefresh() {
|
||||
{{MODEL_NAME}}Cache.forceRefresh();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>All {{MODEL_DISPLAY_NAME}}s - {{PROJECT_NAME}}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="container">
|
||||
<h1>📋 {{PROJECT_NAME}}</h1>
|
||||
|
||||
<nav>
|
||||
<a href="/">Add {{MODEL_DISPLAY_NAME}}</a>
|
||||
<a href="/list" class="active">View All</a>
|
||||
</nav>
|
||||
|
||||
<div class="list-container">
|
||||
<div class="list-header">
|
||||
<h2>All {{MODEL_DISPLAY_NAME}}s</h2>
|
||||
<div class="header-actions">
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search..."
|
||||
bind:value={searchTerm}
|
||||
class="search-input"
|
||||
/>
|
||||
<button class="refresh-btn" onclick={handleRefresh} disabled={status.isSyncing}>
|
||||
{status.isSyncing ? '⏳' : '🔄'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if status.isLoading}
|
||||
<div class="loading">Loading...</div>
|
||||
{:else if filteredRecords.length === 0}
|
||||
<div class="empty">
|
||||
{searchTerm ? 'No matching records found' : 'No {{MODEL_DISPLAY_NAME}}s yet. Add your first one!'}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="record-list">
|
||||
{#each filteredRecords as record (record.id)}
|
||||
<div class="record-card">
|
||||
<div class="record-content">
|
||||
<h3>{record.x_name}</h3>
|
||||
<!-- Add more fields to display -->
|
||||
<p class="record-meta">ID: {record.id}</p>
|
||||
</div>
|
||||
<div class="record-actions">
|
||||
<button class="delete-btn" onclick={() => handleDelete(record.id)}>
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if status.lastSync > 0}
|
||||
<div class="sync-info">
|
||||
Last synced: {new Date(status.lastSync).toLocaleString()}
|
||||
{#if status.isStale}
|
||||
<span class="stale-badge">Stale</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: white;
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
font-size: 2.5em;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
color: #333;
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
nav {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 30px;
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
nav a {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 12px;
|
||||
text-decoration: none;
|
||||
color: #667eea;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
nav a.active {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.list-container {
|
||||
background: white;
|
||||
padding: 24px;
|
||||
border-radius: 15px;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.list-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
padding: 10px;
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.refresh-btn {
|
||||
padding: 10px 15px;
|
||||
background: #f0f0f0;
|
||||
color: #667eea;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.refresh-btn:hover:not(:disabled) {
|
||||
background: #e0e0e0;
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
|
||||
.refresh-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #666;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.record-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.record-card {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 10px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.record-card:hover {
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
.record-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.record-content h3 {
|
||||
margin: 0 0 8px 0;
|
||||
color: #333;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.record-meta {
|
||||
margin: 0;
|
||||
color: #666;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.record-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
padding: 8px 12px;
|
||||
background: #fff5f5;
|
||||
color: #e53e3e;
|
||||
border: 1px solid #feb2b2;
|
||||
border-radius: 6px;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.delete-btn:hover {
|
||||
background: #fed7d7;
|
||||
}
|
||||
|
||||
.sync-info {
|
||||
margin-top: 20px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
text-align: center;
|
||||
color: #666;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.stale-badge {
|
||||
display: inline-block;
|
||||
margin-left: 10px;
|
||||
padding: 2px 8px;
|
||||
background: #fef5e7;
|
||||
color: #f39c12;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.list-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
.record-card {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.record-actions {
|
||||
align-self: flex-end;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,4 @@
|
||||
// Disable SSR for this app (client-side only with Odoo backend)
|
||||
export const ssr = false;
|
||||
export const csr = true;
|
||||
export const prerender = true;
|
||||
@@ -0,0 +1,24 @@
|
||||
<script>
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{{PROJECT_NAME}} - {{MODEL_DISPLAY_NAME}} Manager</title>
|
||||
<meta name="description" content="Offline-first {{MODEL_DISPLAY_NAME}} management app with Odoo integration" />
|
||||
</svelte:head>
|
||||
|
||||
{@render children?.()}
|
||||
|
||||
<style>
|
||||
:global(body) {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu,
|
||||
Cantarell, 'Helvetica Neue', sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
:global(*) {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,271 @@
|
||||
<script>
|
||||
import { {{MODEL_NAME}}Cache } from '$lib/stores/cache';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
|
||||
let name = $state('');
|
||||
let loading = $state(false);
|
||||
let message = $state('');
|
||||
let isOffline = $state(!navigator.onLine);
|
||||
|
||||
// Listen for online/offline events
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('online', () => { isOffline = false; });
|
||||
window.addEventListener('offline', () => { isOffline = true; });
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
await {{MODEL_NAME}}Cache.initialize();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
{{MODEL_NAME}}Cache.destroy();
|
||||
});
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!name.trim()) {
|
||||
message = '⚠️ Please fill in the name field';
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
message = '';
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
x_name: name
|
||||
// Add more fields as needed
|
||||
};
|
||||
|
||||
await {{MODEL_NAME}}Cache.createRecord(payload);
|
||||
|
||||
if (navigator.onLine) {
|
||||
message = '✅ {{MODEL_DISPLAY_NAME}} added successfully!';
|
||||
} else {
|
||||
message = '✅ {{MODEL_DISPLAY_NAME}} saved locally! Will sync when online.';
|
||||
}
|
||||
|
||||
// Reset form
|
||||
name = '';
|
||||
} catch (error) {
|
||||
message = `❌ Error: ${error.message}`;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleRefresh() {
|
||||
{{MODEL_NAME}}Cache.forceRefresh();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Add {{MODEL_DISPLAY_NAME}} - {{PROJECT_NAME}}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="container">
|
||||
<h1>📋 {{PROJECT_NAME}}</h1>
|
||||
|
||||
<!-- Offline Indicator -->
|
||||
{#if isOffline}
|
||||
<div class="offline-banner">
|
||||
📡 Offline Mode - Data will be synced when you're back online
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<nav>
|
||||
<a href="/" class="active">Add {{MODEL_DISPLAY_NAME}}</a>
|
||||
<a href="/list">View All</a>
|
||||
</nav>
|
||||
|
||||
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }}>
|
||||
<div class="form-group">
|
||||
<label for="name">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
bind:value={name}
|
||||
placeholder="Enter {{MODEL_DISPLAY_NAME}} name"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Add more form fields based on your Odoo model -->
|
||||
|
||||
{#if message}
|
||||
<div class="message" class:error={message.includes('❌')}>{message}</div>
|
||||
{/if}
|
||||
|
||||
<div class="button-group">
|
||||
<button type="submit" disabled={loading}>
|
||||
{loading ? '⏳ Adding...' : '➕ Add {{MODEL_DISPLAY_NAME}}'}
|
||||
</button>
|
||||
{#if !isOffline}
|
||||
<button type="button" class="refresh-btn" onclick={handleRefresh}>
|
||||
🔄 Refresh Data
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.container {
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: white;
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
font-size: 2.5em;
|
||||
}
|
||||
|
||||
nav {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 30px;
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
nav a {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 12px;
|
||||
text-decoration: none;
|
||||
color: #667eea;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
nav a.active {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.offline-banner {
|
||||
background: #e3f2fd;
|
||||
color: #1565c0;
|
||||
padding: 12px 20px;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 20px;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
border: 2px solid #64b5f6;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.8; }
|
||||
}
|
||||
|
||||
form {
|
||||
background: white;
|
||||
padding: 24px;
|
||||
border-radius: 15px;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
form {
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
transition: border-color 0.3s;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
button {
|
||||
flex: 1;
|
||||
min-width: 150px;
|
||||
padding: 15px;
|
||||
background: #667eea;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
button:hover:not(:disabled) {
|
||||
background: #5568d3;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 5px 20px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.refresh-btn {
|
||||
background: #f0f0f0;
|
||||
color: #667eea;
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
|
||||
.refresh-btn:hover:not(:disabled) {
|
||||
background: #e0e0e0;
|
||||
color: #5568d3;
|
||||
}
|
||||
|
||||
.message {
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 15px;
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.message.error {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "{{PROJECT_NAME}}",
|
||||
"short_name": "{{PROJECT_NAME}}",
|
||||
"description": "{{MODEL_DISPLAY_NAME}} management app with Odoo integration",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#667eea",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
# Odoo Instance Configuration (for backend server)
|
||||
ODOO_URL=https://your-instance.odoo.com
|
||||
ODOO_DB=your-database-name
|
||||
ODOO_USERNAME=your-username
|
||||
ODOO_API_KEY=your-api-key
|
||||
|
||||
# Primary Model (use x_ prefix for Odoo Studio models)
|
||||
ODOO_PRIMARY_MODEL=x_{{MODEL_NAME}}
|
||||
|
||||
# Frontend: API endpoint (if frontend and backend are separate)
|
||||
VITE_API_URL=http://localhost:3000
|
||||
@@ -0,0 +1,35 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
coverage
|
||||
*.local
|
||||
|
||||
/cypress/videos/
|
||||
/cypress/screenshots/
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
*.tsbuildinfo
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
.env.production
|
||||
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#667eea" />
|
||||
<meta name="description" content="{{MODEL_DISPLAY_NAME}} management PWA with Odoo integration" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<title>{{PROJECT_NAME}}</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "{{PROJECT_NAME}}",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.4.29",
|
||||
"vue-router": "^4.3.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.5",
|
||||
"vite": "^5.3.1",
|
||||
"vite-plugin-pwa": "^0.20.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
import { VitePWA } from 'vite-plugin-pwa';
|
||||
import { fileURLToPath, URL } from 'node:url';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
workbox: {
|
||||
globPatterns: ['**/*.{js,css,html,ico,png,svg,json,webp}']
|
||||
},
|
||||
manifest: {
|
||||
name: '{{PROJECT_NAME}}',
|
||||
short_name: '{{PROJECT_NAME}}',
|
||||
description: 'PWA for {{MODEL_DISPLAY_NAME}} management with Odoo integration',
|
||||
theme_color: '#667eea',
|
||||
background_color: '#ffffff',
|
||||
display: 'standalone',
|
||||
icons: [
|
||||
{
|
||||
src: '/icon-192.png',
|
||||
sizes: '192x192',
|
||||
type: 'image/png'
|
||||
},
|
||||
{
|
||||
src: '/icon-512.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png'
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
}
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: process.env.VITE_API_URL || 'http://localhost:3000',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
74
skills/create-odoo-pwa/templates/vue/src/App.vue.template
Normal file
74
skills/create-odoo-pwa/templates/vue/src/App.vue.template
Normal file
@@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<div class="app">
|
||||
<div class="container">
|
||||
<h1>📋 {{PROJECT_NAME}}</h1>
|
||||
|
||||
<OfflineBanner />
|
||||
|
||||
<nav class="nav">
|
||||
<router-link
|
||||
to="/"
|
||||
:class="{ active: $route.path === '/' }"
|
||||
>
|
||||
Add {{MODEL_DISPLAY_NAME}}
|
||||
</router-link>
|
||||
<router-link
|
||||
to="/list"
|
||||
:class="{ active: $route.path === '/list' }"
|
||||
>
|
||||
View All
|
||||
</router-link>
|
||||
</nav>
|
||||
|
||||
<router-view />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import OfflineBanner from './components/OfflineBanner.vue';
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.app {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: white;
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
font-size: 2.5em;
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 30px;
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.nav a {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 12px;
|
||||
text-decoration: none;
|
||||
color: #667eea;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.nav a.active {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
123
skills/create-odoo-pwa/templates/vue/src/api/odoo.js.template
Normal file
123
skills/create-odoo-pwa/templates/vue/src/api/odoo.js.template
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Odoo API Client for React
|
||||
* Communicates with backend server that proxies to Odoo
|
||||
*/
|
||||
|
||||
class OdooAPI {
|
||||
constructor() {
|
||||
this.apiUrl = import.meta.env.VITE_API_URL
|
||||
? `${import.meta.env.VITE_API_URL}/api/odoo`
|
||||
: '/api/odoo';
|
||||
}
|
||||
|
||||
/**
|
||||
* Call the server-side API
|
||||
* @param {string} action
|
||||
* @param {any} data
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
async callApi(action, data) {
|
||||
const response = await fetch(this.apiUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ action, data })
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'API Error');
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new record in specified model
|
||||
* @param {string} model - Odoo model name
|
||||
* @param {Record<string, any>} fields - Record fields
|
||||
* @returns {Promise<number>} - Created record ID
|
||||
*/
|
||||
async createRecord(model, fields) {
|
||||
const result = await this.callApi('create', { model, fields });
|
||||
return result.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search and read records from specified model
|
||||
* @param {string} model - Odoo model name
|
||||
* @param {any[]} domain - Odoo domain filter
|
||||
* @param {string[]} fields - Fields to retrieve
|
||||
* @returns {Promise<any[]>} - Array of records
|
||||
*/
|
||||
async searchRecords(model, domain = [], fields = []) {
|
||||
const result = await this.callApi('search', { model, domain, fields });
|
||||
return result.results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic search_read for any model
|
||||
* @param {string} model
|
||||
* @param {any[]} domain
|
||||
* @param {string[]} fields
|
||||
* @returns {Promise<any[]>}
|
||||
*/
|
||||
async searchModel(model, domain = [], fields = []) {
|
||||
const result = await this.callApi('search_model', { model, domain, fields });
|
||||
return result.results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch partner list (common operation)
|
||||
* @returns {Promise<Array<{id:number, display_name:string}>>}
|
||||
*/
|
||||
async fetchPartners() {
|
||||
return await this.searchModel('res.partner', [], ['id', 'display_name']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a many2one field value
|
||||
* @param {number|string|null|undefined} id
|
||||
* @returns {number|false}
|
||||
*/
|
||||
formatMany2one(id) {
|
||||
return id ? Number(id) : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a many2many field using the (6,0,[ids]) command
|
||||
* @param {Array<number|string>} ids
|
||||
* @returns {any[]}
|
||||
*/
|
||||
formatMany2many(ids) {
|
||||
if (!Array.isArray(ids) || ids.length === 0) return [];
|
||||
return [[6, 0, ids.map((i) => Number(i))]];
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a record
|
||||
* @param {string} model - Odoo model name
|
||||
* @param {number} id - Record ID
|
||||
* @param {Record<string, any>} values - Fields to update
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async updateRecord(model, id, values) {
|
||||
const result = await this.callApi('update', { model, id, values });
|
||||
return result.result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a record
|
||||
* @param {string} model - Odoo model name
|
||||
* @param {number} id - Record ID
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async deleteRecord(model, id) {
|
||||
const result = await this.callApi('delete', { model, id });
|
||||
return result.result;
|
||||
}
|
||||
}
|
||||
|
||||
export const odooClient = new OdooAPI();
|
||||
@@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<div v-if="isOffline" class="offline-banner">
|
||||
📡 Offline Mode - Data will be synced when you're back online
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue';
|
||||
|
||||
const isOffline = ref(!navigator.onLine);
|
||||
|
||||
const handleOnline = () => isOffline.value = false;
|
||||
const handleOffline = () => isOffline.value = true;
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('online', handleOnline);
|
||||
window.addEventListener('offline', handleOffline);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('online', handleOnline);
|
||||
window.removeEventListener('offline', handleOffline);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.offline-banner {
|
||||
background: #e3f2fd;
|
||||
color: #1565c0;
|
||||
padding: 12px 20px;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 20px;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
border: 2px solid #64b5f6;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,224 @@
|
||||
import { ref, onMounted, onUnmounted } from 'vue';
|
||||
import { odooClient } from '../api/odoo';
|
||||
|
||||
// Cache configuration
|
||||
const CACHE_KEY = '{{MODEL_NAME}}_cache_v1';
|
||||
const CACHE_META_KEY = '{{MODEL_NAME}}_cache_meta_v1';
|
||||
const CACHE_DURATION_MS = 5 * 60 * 1000; // 5 minutes
|
||||
const SYNC_INTERVAL_MS = 3 * 60 * 1000; // 3 minutes
|
||||
|
||||
// Helper functions for localStorage
|
||||
function loadFromStorage() {
|
||||
try {
|
||||
const cachedData = localStorage.getItem(CACHE_KEY);
|
||||
const meta = localStorage.getItem(CACHE_META_KEY);
|
||||
|
||||
if (cachedData && meta) {
|
||||
const records = JSON.parse(cachedData);
|
||||
const metaData = JSON.parse(meta);
|
||||
|
||||
const now = Date.now();
|
||||
const isStale = now - metaData.lastSyncTime > CACHE_DURATION_MS;
|
||||
|
||||
return { records, meta: { ...metaData, isStale } };
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to load cache:', e);
|
||||
}
|
||||
|
||||
return {
|
||||
records: [],
|
||||
meta: {
|
||||
lastSyncTime: 0,
|
||||
lastRecordId: 0,
|
||||
recordCount: 0,
|
||||
isStale: true
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function saveToStorage(records, meta) {
|
||||
try {
|
||||
localStorage.setItem(CACHE_KEY, JSON.stringify(records));
|
||||
localStorage.setItem(CACHE_META_KEY, JSON.stringify({
|
||||
lastSyncTime: meta.lastSyncTime || Date.now(),
|
||||
lastRecordId: meta.lastRecordId || 0,
|
||||
recordCount: records.length,
|
||||
isStale: false
|
||||
}));
|
||||
} catch (e) {
|
||||
console.warn('Failed to save cache:', e);
|
||||
}
|
||||
}
|
||||
|
||||
export function useTaskCache() {
|
||||
const records = ref([]);
|
||||
const loading = ref(false);
|
||||
const syncing = ref(false);
|
||||
const error = ref('');
|
||||
const meta = ref({
|
||||
lastSyncTime: 0,
|
||||
lastRecordId: 0,
|
||||
recordCount: 0,
|
||||
isStale: true
|
||||
});
|
||||
|
||||
let syncInterval = null;
|
||||
|
||||
// Sync function
|
||||
const sync = async (forceFullRefresh = false) => {
|
||||
syncing.value = true;
|
||||
error.value = '';
|
||||
|
||||
try {
|
||||
const fields = ['id', 'x_name'];
|
||||
let domain = [];
|
||||
let fetchedRecords = [];
|
||||
|
||||
if (!forceFullRefresh && meta.value.lastRecordId > 0) {
|
||||
domain = [['id', '>', meta.value.lastRecordId]];
|
||||
try {
|
||||
fetchedRecords = await odooClient.searchRecords('x_{{MODEL_NAME}}', domain, fields);
|
||||
} catch (err) {
|
||||
console.warn('Incremental fetch failed:', err);
|
||||
forceFullRefresh = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (forceFullRefresh || meta.value.lastRecordId === 0) {
|
||||
fetchedRecords = await odooClient.searchRecords('x_{{MODEL_NAME}}', [], fields);
|
||||
}
|
||||
|
||||
let mergedRecords;
|
||||
if (forceFullRefresh || meta.value.lastRecordId === 0) {
|
||||
mergedRecords = fetchedRecords;
|
||||
} else {
|
||||
const existingIds = new Set(records.value.map(r => r.id));
|
||||
const newRecords = fetchedRecords.filter(r => !existingIds.has(r.id));
|
||||
mergedRecords = [...records.value, ...newRecords];
|
||||
}
|
||||
|
||||
mergedRecords.sort((a, b) => a.id - b.id);
|
||||
|
||||
const lastRecordId = mergedRecords.length > 0
|
||||
? Math.max(...mergedRecords.map(r => r.id))
|
||||
: 0;
|
||||
|
||||
const newMeta = {
|
||||
lastSyncTime: Date.now(),
|
||||
lastRecordId,
|
||||
recordCount: mergedRecords.length,
|
||||
isStale: false
|
||||
};
|
||||
|
||||
saveToStorage(mergedRecords, newMeta);
|
||||
|
||||
records.value = mergedRecords;
|
||||
meta.value = newMeta;
|
||||
} catch (err) {
|
||||
console.error('Sync failed:', err);
|
||||
error.value = err.message || 'Failed to sync data';
|
||||
} finally {
|
||||
syncing.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize
|
||||
const initialize = async () => {
|
||||
const cachedState = loadFromStorage();
|
||||
|
||||
if (cachedState.records.length > 0) {
|
||||
records.value = cachedState.records;
|
||||
meta.value = cachedState.meta;
|
||||
|
||||
if (cachedState.meta.isStale) {
|
||||
await sync();
|
||||
}
|
||||
} else {
|
||||
loading.value = true;
|
||||
await sync(true);
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
// Set up periodic sync
|
||||
syncInterval = setInterval(() => {
|
||||
sync();
|
||||
}, SYNC_INTERVAL_MS);
|
||||
};
|
||||
|
||||
// Cleanup
|
||||
const cleanup = () => {
|
||||
if (syncInterval) {
|
||||
clearInterval(syncInterval);
|
||||
syncInterval = null;
|
||||
}
|
||||
};
|
||||
|
||||
// Force refresh
|
||||
const forceRefresh = async () => {
|
||||
await sync(true);
|
||||
};
|
||||
|
||||
// Create record
|
||||
const createRecord = async (fields) => {
|
||||
try {
|
||||
const id = await odooClient.createRecord('x_{{MODEL_NAME}}', fields);
|
||||
const newRecord = { id, ...fields };
|
||||
records.value = [...records.value, newRecord];
|
||||
await sync();
|
||||
return id;
|
||||
} catch (err) {
|
||||
console.error('Failed to create record:', err);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
// Update record
|
||||
const updateRecord = async (id, values) => {
|
||||
try {
|
||||
await odooClient.updateRecord('x_{{MODEL_NAME}}', id, values);
|
||||
records.value = records.value.map(r =>
|
||||
r.id === id ? { ...r, ...values } : r
|
||||
);
|
||||
await sync();
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('Failed to update record:', err);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
// Delete record
|
||||
const deleteRecord = async (id) => {
|
||||
try {
|
||||
await odooClient.deleteRecord('x_{{MODEL_NAME}}', id);
|
||||
records.value = records.value.filter(r => r.id !== id);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('Failed to delete record:', err);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-initialize and cleanup on component lifecycle
|
||||
onMounted(() => {
|
||||
initialize();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
return {
|
||||
records,
|
||||
loading,
|
||||
syncing,
|
||||
error,
|
||||
meta,
|
||||
sync,
|
||||
forceRefresh,
|
||||
createRecord,
|
||||
updateRecord,
|
||||
deleteRecord
|
||||
};
|
||||
}
|
||||
18
skills/create-odoo-pwa/templates/vue/src/main.js.template
Normal file
18
skills/create-odoo-pwa/templates/vue/src/main.js.template
Normal file
@@ -0,0 +1,18 @@
|
||||
import { createApp } from 'vue';
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
import App from './App.vue';
|
||||
import AddPage from './pages/AddPage.vue';
|
||||
import ListPage from './pages/ListPage.vue';
|
||||
import './style.css';
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{ path: '/', component: AddPage },
|
||||
{ path: '/list', component: ListPage }
|
||||
]
|
||||
});
|
||||
|
||||
createApp(App)
|
||||
.use(router)
|
||||
.mount('#app');
|
||||
@@ -0,0 +1,161 @@
|
||||
<template>
|
||||
<form @submit.prevent="handleSubmit" class="form">
|
||||
<div class="form-group">
|
||||
<label for="name">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
v-model="name"
|
||||
placeholder="Enter {{MODEL_DISPLAY_NAME}} name"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="message" :class="['message', { error: message.includes('❌') }]">
|
||||
{{ message }}
|
||||
</div>
|
||||
|
||||
<div class="button-group">
|
||||
<button type="submit" :disabled="loading">
|
||||
{{ loading ? '⏳ Adding...' : '➕ Add {{MODEL_DISPLAY_NAME}}' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="isOnline"
|
||||
type="button"
|
||||
class="refresh-btn"
|
||||
@click="forceRefresh"
|
||||
>
|
||||
🔄 Refresh Data
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useTaskCache } from '../composables/useTaskCache';
|
||||
|
||||
const { createRecord, forceRefresh } = useTaskCache();
|
||||
|
||||
const name = ref('');
|
||||
const loading = ref(false);
|
||||
const message = ref('');
|
||||
const isOnline = computed(() => navigator.onLine);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!name.value.trim()) {
|
||||
message.value = '⚠️ Please fill in the name field';
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
message.value = '';
|
||||
|
||||
try {
|
||||
const payload = { x_name: name.value };
|
||||
await createRecord(payload);
|
||||
|
||||
if (navigator.onLine) {
|
||||
message.value = '✅ {{MODEL_DISPLAY_NAME}} added successfully!';
|
||||
} else {
|
||||
message.value = '✅ {{MODEL_DISPLAY_NAME}} saved locally! Will sync when online.';
|
||||
}
|
||||
|
||||
name.value = '';
|
||||
} catch (error) {
|
||||
message.value = `❌ Error: ${error.message}`;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.form {
|
||||
background: white;
|
||||
padding: 24px;
|
||||
border-radius: 15px;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
transition: border-color 0.3s;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
button {
|
||||
flex: 1;
|
||||
min-width: 150px;
|
||||
padding: 15px;
|
||||
background: #667eea;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
button:hover:not(:disabled) {
|
||||
background: #5568d3;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 5px 20px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.refresh-btn {
|
||||
background: #f0f0f0;
|
||||
color: #667eea;
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
|
||||
.refresh-btn:hover:not(:disabled) {
|
||||
background: #e0e0e0;
|
||||
color: #5568d3;
|
||||
}
|
||||
|
||||
.message {
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 15px;
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.message.error {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,200 @@
|
||||
<template>
|
||||
<div class="list-container">
|
||||
<div class="list-header">
|
||||
<h2>All {{MODEL_DISPLAY_NAME}}s</h2>
|
||||
<div class="header-actions">
|
||||
<input
|
||||
type="search"
|
||||
v-model="searchTerm"
|
||||
placeholder="Search..."
|
||||
class="search-input"
|
||||
/>
|
||||
<button
|
||||
class="refresh-btn-small"
|
||||
@click="forceRefresh"
|
||||
:disabled="syncing"
|
||||
>
|
||||
{{ syncing ? '⏳' : '🔄' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
<div v-else-if="filteredRecords.length === 0" class="empty">
|
||||
{{ searchTerm ? 'No matching records found' : 'No {{MODEL_DISPLAY_NAME}}s yet. Add your first one!' }}
|
||||
</div>
|
||||
<div v-else class="record-list">
|
||||
<div
|
||||
v-for="record in filteredRecords"
|
||||
:key="record.id"
|
||||
class="record-card"
|
||||
>
|
||||
<div class="record-content">
|
||||
<h3>{{ record.x_name }}</h3>
|
||||
<p class="record-meta">ID: {{ record.id }}</p>
|
||||
</div>
|
||||
<div class="record-actions">
|
||||
<button class="delete-btn" @click="handleDelete(record.id)">
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useTaskCache } from '../composables/useTaskCache';
|
||||
|
||||
const { records, loading, syncing, forceRefresh, deleteRecord } = useTaskCache();
|
||||
|
||||
const searchTerm = ref('');
|
||||
|
||||
const filteredRecords = computed(() => {
|
||||
return records.value.filter(record =>
|
||||
record.x_name?.toLowerCase().includes(searchTerm.value.toLowerCase())
|
||||
);
|
||||
});
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
if (!confirm('Are you sure you want to delete this {{MODEL_DISPLAY_NAME}}?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteRecord(id);
|
||||
} catch (error) {
|
||||
alert(`Failed to delete: ${error.message}`);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.list-container {
|
||||
background: white;
|
||||
padding: 24px;
|
||||
border-radius: 15px;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.list-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
color: #333;
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
padding: 10px;
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.refresh-btn-small {
|
||||
padding: 10px 15px;
|
||||
background: #f0f0f0;
|
||||
color: #667eea;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.refresh-btn-small:hover:not(:disabled) {
|
||||
background: #e0e0e0;
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
|
||||
.refresh-btn-small:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #666;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.record-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.record-card {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 10px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.record-card:hover {
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
.record-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.record-content h3 {
|
||||
margin: 0 0 8px 0;
|
||||
color: #333;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.record-meta {
|
||||
margin: 0;
|
||||
color: #666;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.record-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
padding: 8px 12px;
|
||||
background: #fff5f5;
|
||||
color: #e53e3e;
|
||||
border: 1px solid #feb2b2;
|
||||
border-radius: 6px;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.delete-btn:hover {
|
||||
background: #fed7d7;
|
||||
}
|
||||
</style>
|
||||
29
skills/create-odoo-pwa/templates/vue/src/style.css.template
Normal file
29
skills/create-odoo-pwa/templates/vue/src/style.css.template
Normal file
@@ -0,0 +1,29 @@
|
||||
:root {
|
||||
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu,
|
||||
Cantarell, 'Helvetica Neue', sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
#app {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
}
|
||||
Reference in New Issue
Block a user