Adapt to mobile

This commit is contained in:
dvirlabs 2025-12-19 00:00:15 +02:00
parent 8094192d7a
commit 7a34f5f990
13 changed files with 861 additions and 19 deletions

Binary file not shown.

View File

@ -20,7 +20,7 @@ allowed_origins = ["http://localhost:5173", "https://tasko.dvirlabs.com"]
# Configure CORS # Configure CORS
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=allowed_origins, allow_origins=["*"],
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],
@ -419,4 +419,4 @@ def delete_task(task_id: str, authorization: Optional[str] = Header(None), db: S
if __name__ == "__main__": if __name__ == "__main__":
import uvicorn import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000) uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)

145
frontend/PWA_SETUP.md Normal file
View File

@ -0,0 +1,145 @@
# PWA Installation Guide
Your Tasko app is now a Progressive Web App (PWA)! Users can install it on their phones and use it like a native app.
## What's Been Added
### 1. **manifest.json**
- App name, icons, and theme colors
- Tells the browser how to install the app
### 2. **Service Worker**
- Enables offline functionality
- Caches app resources for faster loading
### 3. **Install Prompt**
- Beautiful bottom banner prompts users to install
- Shows on mobile devices automatically
### 4. **App Icons**
- Multiple sizes for different devices (72px to 512px)
## Setting Up Icons
### Option 1: Use the Icon Generator (Easiest)
1. Open `frontend/generate-icons.html` in your browser
2. Icons will auto-generate
3. Right-click each icon and "Save image as..."
4. Save them in `frontend/public/` with exact filenames shown
### Option 2: Create Custom Icons
1. Create PNG images in these sizes:
- 72x72, 96x96, 128x128, 144x144, 152x152, 192x192, 384x384, 512x512
2. Name them as: `icon-{size}x{size}.png`
3. Place in `frontend/public/` folder
### Option 3: Use an Online Tool
- [PWA Asset Generator](https://www.pwabuilder.com/)
- [RealFaviconGenerator](https://realfavicongenerator.net/)
## Testing the PWA
### On Mobile:
1. Build and deploy your app (or use npm run dev --host)
2. Open the app in Chrome/Safari on your phone
3. You'll see the install prompt at the bottom
4. Tap "Install" to add to home screen
### On Desktop (Chrome):
1. Open DevTools (F12)
2. Go to Application > Manifest
3. Click "Add to home screen" to test
### Testing Install Prompt:
```bash
# In Chrome DevTools Console:
localStorage.removeItem('installPromptDismissed')
# Then refresh the page
```
## Features Users Get
**Home Screen Icon** - App appears on phone like any other app
**Splash Screen** - Beautiful loading screen with your branding
**Standalone Mode** - Runs without browser UI (feels native)
**Offline Support** - Works without internet (basic functionality)
**Fast Loading** - Cached resources load instantly
## How It Works
### Install Flow:
1. User visits your app on mobile
2. After a few seconds, install banner appears
3. User taps "Install"
4. App is added to home screen
5. User can launch from home screen like a native app
### Browser Support:
- ✅ Chrome/Edge (Android & Desktop)
- ✅ Safari (iOS 11.3+)
- ✅ Samsung Internet
- ✅ Firefox (Android)
## Customization
### Change App Name:
Edit `manifest.json`:
```json
{
"name": "Your App Name",
"short_name": "ShortName"
}
```
### Change Colors:
Edit `manifest.json`:
```json
{
"theme_color": "#667eea",
"background_color": "#667eea"
}
```
### Update Icons:
Replace the icon files in `public/` folder
## Deployment
### For Production:
1. Build your app: `npm run build`
2. Serve with HTTPS (required for PWA)
3. Icons and manifest will be included automatically
### HTTPS Requirement:
PWAs require HTTPS (except localhost). Use:
- Vercel
- Netlify
- GitHub Pages
- Any hosting with SSL certificate
## Troubleshooting
### Install prompt not showing?
- Clear browser cache
- Remove: `localStorage.removeItem('installPromptDismissed')`
- Make sure you're on HTTPS (or localhost)
- Some browsers show it after 2-3 visits
### Icons not appearing?
- Check file names match exactly: `icon-192x192.png`
- Verify they're in `public/` folder
- Clear cache and rebuild
### Service worker not registering?
- Check browser console for errors
- Make sure `service-worker.js` is in `public/` folder
- Try hard refresh (Ctrl+Shift+R)
## Next Steps
1. Generate/add your app icons
2. Test on a real mobile device
3. Deploy to a hosting service with HTTPS
4. Share the link with users!
Users will now see an install banner and can add your app to their home screen! 🎉

View File

@ -0,0 +1,132 @@
<!DOCTYPE html>
<html>
<head>
<title>Tasko Icon Generator</title>
<style>
body {
font-family: Arial, sans-serif;
padding: 20px;
background: #f5f5f5;
}
.container {
max-width: 800px;
margin: 0 auto;
background: white;
padding: 30px;
border-radius: 10px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
h1 { color: #667eea; }
.instructions {
background: #f0f0f0;
padding: 15px;
border-radius: 5px;
margin: 20px 0;
}
canvas {
border: 2px solid #ddd;
margin: 10px;
border-radius: 8px;
}
button {
background: #667eea;
color: white;
border: none;
padding: 12px 24px;
border-radius: 8px;
cursor: pointer;
font-size: 16px;
margin-top: 20px;
}
button:hover {
background: #5568d3;
}
.icons-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 20px;
margin-top: 20px;
}
.icon-item {
text-align: center;
}
</style>
</head>
<body>
<div class="container">
<h1>✓ Tasko Icon Generator</h1>
<div class="instructions">
<h3>Instructions:</h3>
<ol>
<li>Click "Generate Icons" button below</li>
<li>Right-click each icon and "Save image as..."</li>
<li>Save them in the <code>frontend/public/</code> folder with the exact names shown</li>
<li>Restart your dev server</li>
</ol>
</div>
<button onclick="generateIcons()">Generate Icons</button>
<div class="icons-grid" id="iconsGrid"></div>
</div>
<script>
const sizes = [72, 96, 128, 144, 152, 192, 384, 512];
function generateIcons() {
const grid = document.getElementById('iconsGrid');
grid.innerHTML = '';
sizes.forEach(size => {
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
// Create gradient background
const gradient = ctx.createLinearGradient(0, 0, size, size);
gradient.addColorStop(0, '#667eea');
gradient.addColorStop(1, '#764ba2');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, size, size);
// Add rounded corners
ctx.globalCompositeOperation = 'destination-in';
ctx.beginPath();
ctx.roundRect(0, 0, size, size, size * 0.2);
ctx.fill();
ctx.globalCompositeOperation = 'source-over';
// Draw checkmark
ctx.strokeStyle = 'white';
ctx.lineWidth = size * 0.1;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.beginPath();
ctx.moveTo(size * 0.25, size * 0.5);
ctx.lineTo(size * 0.4, size * 0.65);
ctx.lineTo(size * 0.75, size * 0.35);
ctx.stroke();
const item = document.createElement('div');
item.className = 'icon-item';
item.innerHTML = `
<canvas width="${size}" height="${size}" style="width: ${Math.min(size, 150)}px; height: ${Math.min(size, 150)}px;"></canvas>
<p><strong>${size}x${size}</strong></p>
<p><small>icon-${size}x${size}.png</small></p>
`;
const itemCanvas = item.querySelector('canvas');
itemCanvas.getContext('2d').drawImage(canvas, 0, 0);
grid.appendChild(item);
});
}
// Auto-generate on load
window.onload = generateIcons;
</script>
</body>
</html>

View File

@ -2,9 +2,24 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/icon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0, user-scalable=yes" />
<title>frontend</title> <meta name="theme-color" content="#667eea" />
<meta name="description" content="Modern task management app" />
<!-- PWA Configuration -->
<link rel="manifest" href="/manifest.json" />
<link rel="apple-touch-icon" href="/icon.svg" />
<!-- iOS Specific -->
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Tasko" />
<!-- Android -->
<meta name="mobile-web-app-capable" content="yes" />
<title>✓ Tasko - Task Manager</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

10
frontend/public/icon.svg Normal file
View File

@ -0,0 +1,10 @@
<svg width="192" height="192" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#667eea;stop-opacity:1" />
<stop offset="100%" style="stop-color:#764ba2;stop-opacity:1" />
</linearGradient>
</defs>
<rect width="192" height="192" rx="38" fill="url(#grad)"/>
<path d="M 48 96 L 77 125 L 144 58" stroke="white" stroke-width="20" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 514 B

View File

@ -0,0 +1,32 @@
{
"name": "Tasko - Task Manager",
"short_name": "Tasko",
"description": "Modern task management app for organizing your work and life",
"start_url": "/",
"display": "standalone",
"display_override": ["standalone", "fullscreen"],
"background_color": "#667eea",
"theme_color": "#667eea",
"orientation": "portrait-primary",
"scope": "/",
"icons": [
{
"src": "/icon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any maskable"
},
{
"src": "/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}

View File

@ -0,0 +1,53 @@
const CACHE_NAME = 'tasko-v1';
const urlsToCache = [
'/',
'/index.html',
'/src/main.jsx',
'/src/App.jsx',
'/src/App.css',
'/src/Auth.jsx',
'/src/Auth.css',
'/src/index.css'
];
// Install event - cache resources
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then((cache) => {
console.log('Opened cache');
return cache.addAll(urlsToCache);
})
);
});
// Fetch event - serve from cache, fallback to network
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request)
.then((response) => {
// Cache hit - return response
if (response) {
return response;
}
return fetch(event.request);
}
)
);
});
// Activate event - clean up old caches
self.addEventListener('activate', (event) => {
const cacheWhitelist = [CACHE_NAME];
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames.map((cacheName) => {
if (cacheWhitelist.indexOf(cacheName) === -1) {
return caches.delete(cacheName);
}
})
);
})
);
});

View File

@ -863,33 +863,344 @@
background: #252538; background: #252538;
} }
/* Mobile Menu Toggle */
.mobile-menu-toggle {
display: none;
position: fixed;
top: 1rem;
left: 1rem;
z-index: 1001;
width: 48px;
height: 48px;
border: none;
border-radius: 12px;
background: rgba(255, 255, 255, 0.95);
color: #667eea;
font-size: 1.5rem;
cursor: pointer;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
transition: all 0.3s;
}
.mobile-menu-toggle:hover {
background: white;
transform: scale(1.05);
}
.mobile-menu-toggle:active {
transform: scale(0.95);
}
/* Mobile Overlay */
.mobile-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 999;
}
/* Mobile Close Button */
.mobile-close-btn {
display: none;
position: absolute;
top: 1rem;
right: 1rem;
width: 40px;
height: 40px;
border: none;
border-radius: 8px;
background: #f5f5f5;
color: #666;
font-size: 1.5rem;
cursor: pointer;
transition: all 0.3s;
z-index: 10;
}
.mobile-close-btn:hover {
background: #e5e5e5;
transform: rotate(90deg);
}
/* Dark mode mobile buttons */
.dark-mode .mobile-menu-toggle {
background: rgba(42, 42, 62, 0.95);
color: #8b9bea;
}
.dark-mode .mobile-menu-toggle:hover {
background: #2a2a3e;
}
.dark-mode .mobile-close-btn {
background: #1a1a2e;
color: #b0b0c0;
}
.dark-mode .mobile-close-btn:hover {
background: #252538;
}
@media (max-width: 768px) { @media (max-width: 768px) {
.app { .app {
flex-direction: column; flex-direction: column;
} }
.sidebar { /* Show mobile menu toggle */
width: 100%; .mobile-menu-toggle {
max-height: 30vh; display: flex;
align-items: center;
justify-content: center;
} }
/* Show mobile overlay when menu is open */
.mobile-overlay {
display: block;
}
/* Show mobile close button */
.mobile-close-btn {
display: block;
}
/* Sidebar mobile styles */
.sidebar {
position: fixed;
top: 0;
left: -100%;
width: 85%;
max-width: 320px;
height: 100vh;
z-index: 1000;
transition: left 0.3s ease;
padding-top: 4rem;
}
.sidebar.mobile-open {
left: 0;
box-shadow: 4px 0 20px rgba(0, 0, 0, 0.3);
}
/* Main content adjustments */
.main-content { .main-content {
padding: 1.5rem; width: 100%;
padding: 5rem 1rem 1rem;
margin-left: 0;
}
.content-header {
flex-direction: column;
align-items: flex-start;
gap: 1rem;
} }
.content-title { .content-title {
font-size: 2rem; font-size: 1.75rem;
} }
.content-title span { .content-title span {
font-size: 2.5rem; font-size: 2rem;
} }
/* Task form mobile */
.task-form { .task-form {
flex-direction: column; flex-direction: column;
gap: 0.75rem;
}
.task-input {
font-size: 1rem;
} }
.add-button { .add-button {
width: 100%; width: 100%;
padding: 0.875rem;
font-size: 1rem;
}
/* Filter tabs mobile */
.filter-tabs {
gap: 0.5rem;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.filter-tabs button {
font-size: 0.875rem;
padding: 0.5rem 1rem;
white-space: nowrap;
}
/* Tasks list mobile */
.tasks-list {
gap: 0.75rem;
}
.task-item {
padding: 1rem;
}
.task-title {
font-size: 0.95rem;
}
.delete-button {
width: 32px;
height: 32px;
font-size: 1.25rem;
}
/* Task edit buttons mobile */
.task-edit-buttons {
gap: 0.5rem;
}
.task-edit-buttons button {
padding: 0.5rem 0.875rem;
font-size: 0.875rem;
}
/* New list form mobile */
.new-list-form {
padding: 1rem;
}
.icon-grid {
grid-template-columns: repeat(6, 1fr);
gap: 0.5rem;
}
.icon-grid button {
width: 40px;
height: 40px;
font-size: 1.25rem;
}
/* Modal mobile */
.modal-content {
width: 90%;
max-width: 360px;
padding: 1.5rem;
margin: 1rem;
}
.modal-title {
font-size: 1.25rem;
}
.modal-message {
font-size: 0.95rem;
}
.modal-actions {
flex-direction: column-reverse;
gap: 0.75rem;
}
.modal-actions button {
width: 100%;
padding: 0.875rem;
}
/* Empty states mobile */
.empty-state,
.empty-state-main {
font-size: 2rem;
padding: 2rem 1rem;
}
/* User info mobile */
.username {
font-size: 0.85rem;
}
.logout-btn {
padding: 0.35rem 0.6rem;
font-size: 0.7rem;
}
/* Sidebar header mobile */
.sidebar-title {
font-size: 1.5rem;
}
.theme-toggle {
width: 32px;
height: 32px;
font-size: 1rem;
}
/* List items mobile */
.list-item {
padding: 0.875rem;
}
.list-icon {
font-size: 1.25rem;
}
.list-name {
font-size: 0.95rem;
}
.list-delete-btn {
width: 28px;
height: 28px;
font-size: 1.25rem;
}
/* Add list button mobile */
.add-list-button {
padding: 0.875rem;
font-size: 0.95rem;
} }
} }
/* Small mobile devices */
@media (max-width: 480px) {
.mobile-menu-toggle {
width: 44px;
height: 44px;
font-size: 1.35rem;
}
.sidebar {
width: 90%;
}
.content-title {
font-size: 1.5rem;
}
.content-title span {
font-size: 1.75rem;
}
.icon-grid {
grid-template-columns: repeat(5, 1fr);
}
.modal-content {
width: 95%;
padding: 1.25rem;
}
}
/* Landscape mobile */
@media (max-width: 896px) and (orientation: landscape) {
.sidebar {
max-width: 280px;
}
.main-content {
padding: 4rem 1.5rem 1rem;
}
}
@media (max-width: 768px) {
/* Removed duplicate - styles are above */
}

View File

@ -2,7 +2,7 @@ import { useState, useEffect } from 'react'
import Auth from './Auth' import Auth from './Auth'
import './App.css' import './App.css'
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000' const API_URL = import.meta.env.VITE_API_URL || 'http://10.188.50.221:8000'
const AVAILABLE_ICONS = [ const AVAILABLE_ICONS = [
'📝', '💼', '🏠', '🛒', '🎯', '💪', '📚', '✈️', '📝', '💼', '🏠', '🛒', '🎯', '💪', '📚', '✈️',
@ -27,6 +27,34 @@ function App() {
const [editingTaskTitle, setEditingTaskTitle] = useState('') const [editingTaskTitle, setEditingTaskTitle] = useState('')
const [confirmModal, setConfirmModal] = useState({ show: false, message: '', onConfirm: null }) const [confirmModal, setConfirmModal] = useState({ show: false, message: '', onConfirm: null })
const [darkMode, setDarkMode] = useState(false) const [darkMode, setDarkMode] = useState(false)
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
const [deferredPrompt, setDeferredPrompt] = useState(null)
useEffect(() => {
// Capture the install prompt event
const handleBeforeInstallPrompt = (e) => {
// Prevent Chrome 67 and earlier from automatically showing the prompt
e.preventDefault()
// Stash the event so it can be triggered later
setDeferredPrompt(e)
// Automatically show the prompt
setTimeout(() => {
e.prompt()
e.userChoice.then((choiceResult) => {
if (choiceResult.outcome === 'accepted') {
console.log('User accepted the install prompt')
}
setDeferredPrompt(null)
})
}, 1000)
}
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
return () => {
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
}
}, [])
useEffect(() => { useEffect(() => {
// Check for stored token on mount // Check for stored token on mount
@ -276,7 +304,31 @@ function App() {
return ( return (
<div className={`app ${darkMode ? 'dark-mode' : ''}`}> <div className={`app ${darkMode ? 'dark-mode' : ''}`}>
<div className="sidebar"> {/* Mobile Menu Toggle */}
<button
className="mobile-menu-toggle"
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
aria-label="Toggle menu"
>
{mobileMenuOpen ? '✕' : '☰'}
</button>
{/* Mobile Overlay */}
{mobileMenuOpen && (
<div
className="mobile-overlay"
onClick={() => setMobileMenuOpen(false)}
/>
)}
<div className={`sidebar ${mobileMenuOpen ? 'mobile-open' : ''}`}>
<button
className="mobile-close-btn"
onClick={() => setMobileMenuOpen(false)}
aria-label="Close menu"
>
</button>
<div className="sidebar-header"> <div className="sidebar-header">
<div className="header-top"> <div className="header-top">
<h2 className="sidebar-title"> Tasko</h2> <h2 className="sidebar-title"> Tasko</h2>
@ -298,7 +350,10 @@ function App() {
<div <div
key={list.id} key={list.id}
className={`list-item ${selectedList?.id === list.id ? 'active' : ''}`} className={`list-item ${selectedList?.id === list.id ? 'active' : ''}`}
onClick={() => setSelectedList(list)} onClick={() => {
setSelectedList(list)
setMobileMenuOpen(false)
}}
> >
<div className="list-info"> <div className="list-info">
<span className="list-icon" style={{ color: list.color }}>{list.icon}</span> <span className="list-icon" style={{ color: list.color }}>{list.icon}</span>

View File

@ -131,12 +131,88 @@
cursor: not-allowed; cursor: not-allowed;
} }
@media (max-width: 640px) { /* Mobile responsive styles */
@media (max-width: 768px) {
.auth-container {
padding: 1rem;
}
.auth-box { .auth-box {
padding: 2rem; padding: 2rem 1.5rem;
border-radius: 16px;
} }
.auth-title { .auth-title {
font-size: 2.5rem; font-size: 2.25rem;
}
.auth-subtitle {
font-size: 0.95rem;
margin-bottom: 1.5rem;
}
.auth-tabs {
margin-bottom: 1.5rem;
}
.auth-tabs button {
padding: 0.65rem 0.875rem;
font-size: 0.95rem;
}
.auth-form {
gap: 1.25rem;
}
.form-group input {
padding: 0.875rem;
font-size: 1rem;
}
.auth-submit {
padding: 0.875rem 1.5rem;
font-size: 1rem;
}
.auth-error {
padding: 0.875rem;
font-size: 0.9rem;
} }
} }
@media (max-width: 480px) {
.auth-box {
padding: 1.75rem 1.25rem;
}
.auth-title {
font-size: 2rem;
}
.auth-subtitle {
font-size: 0.9rem;
}
.auth-tabs button {
padding: 0.6rem 0.75rem;
font-size: 0.9rem;
}
.form-group label {
font-size: 0.85rem;
}
.form-group input {
padding: 0.75rem;
font-size: 0.95rem;
}
.auth-submit {
padding: 0.8rem 1.25rem;
font-size: 0.95rem;
}
}
@media (max-width: 640px) {
/* Removed duplicate - styles are above */
}

View File

@ -1,7 +1,7 @@
import { useState } from 'react' import { useState } from 'react'
import './Auth.css' import './Auth.css'
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000' const API_URL = import.meta.env.VITE_API_URL || 'http://10.188.50.221:8000'
function Auth({ onLogin }) { function Auth({ onLogin }) {
const [isLogin, setIsLogin] = useState(true) const [isLogin, setIsLogin] = useState(true)

View File

@ -8,3 +8,16 @@ createRoot(document.getElementById('root')).render(
<App /> <App />
</StrictMode>, </StrictMode>,
) )
// Register service worker for PWA
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/service-worker.js')
.then((registration) => {
console.log('SW registered: ', registration);
})
.catch((error) => {
console.log('SW registration failed: ', error);
});
});
}