Initial commit - workspace setup
- AGENTS.md, SOUL.md, USER.md, IDENTITY.md - ANAF monitor (declarații fiscale) - Kanban board + Notes UI - Email tools - Memory system
This commit is contained in:
306
kanban/api.py
Normal file
306
kanban/api.py
Normal file
@@ -0,0 +1,306 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Simple API server for Echo Task Board.
|
||||
Handles YouTube summarization requests.
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import re
|
||||
import os
|
||||
from http.server import HTTPServer, SimpleHTTPRequestHandler
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
BASE_DIR = Path(__file__).parent.parent
|
||||
TOOLS_DIR = BASE_DIR / 'tools'
|
||||
NOTES_DIR = BASE_DIR / 'notes' / 'youtube'
|
||||
KANBAN_DIR = BASE_DIR / 'kanban'
|
||||
|
||||
class TaskBoardHandler(SimpleHTTPRequestHandler):
|
||||
|
||||
def do_POST(self):
|
||||
if self.path == '/api/youtube':
|
||||
self.handle_youtube()
|
||||
elif self.path == '/api/files':
|
||||
self.handle_files_post()
|
||||
else:
|
||||
self.send_error(404)
|
||||
|
||||
def handle_files_post(self):
|
||||
"""Save file content."""
|
||||
try:
|
||||
content_length = int(self.headers['Content-Length'])
|
||||
post_data = self.rfile.read(content_length).decode('utf-8')
|
||||
data = json.loads(post_data)
|
||||
|
||||
path = data.get('path', '')
|
||||
content = data.get('content', '')
|
||||
|
||||
workspace = Path('/home/moltbot/clawd')
|
||||
target = (workspace / path).resolve()
|
||||
|
||||
if not str(target).startswith(str(workspace)):
|
||||
self.send_json({'error': 'Access denied'}, 403)
|
||||
return
|
||||
|
||||
# Create parent dirs if needed
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Write file
|
||||
target.write_text(content, encoding='utf-8')
|
||||
|
||||
self.send_json({
|
||||
'status': 'saved',
|
||||
'path': path,
|
||||
'size': len(content)
|
||||
})
|
||||
except Exception as e:
|
||||
self.send_json({'error': str(e)}, 500)
|
||||
|
||||
def do_GET(self):
|
||||
if self.path == '/api/status':
|
||||
self.send_json({'status': 'ok', 'time': datetime.now().isoformat()})
|
||||
elif self.path.startswith('/api/files'):
|
||||
self.handle_files_get()
|
||||
elif self.path.startswith('/api/'):
|
||||
self.send_error(404)
|
||||
else:
|
||||
# Serve static files
|
||||
super().do_GET()
|
||||
|
||||
def handle_files_get(self):
|
||||
"""List files or get file content."""
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
parsed = urlparse(self.path)
|
||||
params = parse_qs(parsed.query)
|
||||
|
||||
path = params.get('path', [''])[0]
|
||||
action = params.get('action', ['list'])[0]
|
||||
|
||||
# Security: only allow access within workspace
|
||||
workspace = Path('/home/moltbot/clawd')
|
||||
try:
|
||||
target = (workspace / path).resolve()
|
||||
if not str(target).startswith(str(workspace)):
|
||||
self.send_json({'error': 'Access denied'}, 403)
|
||||
return
|
||||
except:
|
||||
self.send_json({'error': 'Invalid path'}, 400)
|
||||
return
|
||||
|
||||
if action == 'list':
|
||||
if not target.exists():
|
||||
self.send_json({'error': 'Path not found'}, 404)
|
||||
return
|
||||
|
||||
if target.is_file():
|
||||
# Return file content
|
||||
try:
|
||||
content = target.read_text(encoding='utf-8', errors='replace')
|
||||
self.send_json({
|
||||
'type': 'file',
|
||||
'path': path,
|
||||
'name': target.name,
|
||||
'content': content[:100000], # Limit to 100KB
|
||||
'size': target.stat().st_size,
|
||||
'truncated': target.stat().st_size > 100000
|
||||
})
|
||||
except Exception as e:
|
||||
self.send_json({'error': str(e)}, 500)
|
||||
else:
|
||||
# List directory
|
||||
items = []
|
||||
try:
|
||||
for item in sorted(target.iterdir()):
|
||||
if item.name.startswith('.'):
|
||||
continue
|
||||
items.append({
|
||||
'name': item.name,
|
||||
'type': 'dir' if item.is_dir() else 'file',
|
||||
'size': item.stat().st_size if item.is_file() else None,
|
||||
'path': str(item.relative_to(workspace))
|
||||
})
|
||||
self.send_json({
|
||||
'type': 'dir',
|
||||
'path': path,
|
||||
'items': items
|
||||
})
|
||||
except Exception as e:
|
||||
self.send_json({'error': str(e)}, 500)
|
||||
else:
|
||||
self.send_json({'error': 'Unknown action'}, 400)
|
||||
|
||||
def handle_youtube(self):
|
||||
try:
|
||||
content_length = int(self.headers['Content-Length'])
|
||||
post_data = self.rfile.read(content_length).decode('utf-8')
|
||||
data = json.loads(post_data)
|
||||
|
||||
url = data.get('url', '').strip()
|
||||
|
||||
if not url or 'youtube.com' not in url and 'youtu.be' not in url:
|
||||
self.send_json({'error': 'URL YouTube invalid'}, 400)
|
||||
return
|
||||
|
||||
# Process synchronously (simpler, avoids fork issues)
|
||||
try:
|
||||
print(f"Processing YouTube URL: {url}")
|
||||
result = process_youtube(url)
|
||||
print(f"Processing result: {result}")
|
||||
self.send_json({
|
||||
'status': 'done',
|
||||
'message': 'Notița a fost creată! Refresh pagina Notes.'
|
||||
})
|
||||
except Exception as e:
|
||||
import traceback
|
||||
print(f"YouTube processing error: {e}")
|
||||
traceback.print_exc()
|
||||
self.send_json({
|
||||
'status': 'error',
|
||||
'message': f'Eroare: {str(e)}'
|
||||
}, 500)
|
||||
|
||||
except Exception as e:
|
||||
self.send_json({'error': str(e)}, 500)
|
||||
|
||||
def send_json(self, data, code=200):
|
||||
self.send_response(code)
|
||||
self.send_header('Content-Type', 'application/json')
|
||||
self.send_header('Access-Control-Allow-Origin', '*')
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps(data).encode())
|
||||
|
||||
def do_OPTIONS(self):
|
||||
self.send_response(200)
|
||||
self.send_header('Access-Control-Allow-Origin', '*')
|
||||
self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
|
||||
self.send_header('Access-Control-Allow-Headers', 'Content-Type')
|
||||
self.end_headers()
|
||||
|
||||
|
||||
def process_youtube(url):
|
||||
"""Download subtitles, summarize, save note."""
|
||||
import time
|
||||
|
||||
# Get video info and subtitles
|
||||
yt_dlp = os.path.expanduser('~/.local/bin/yt-dlp')
|
||||
|
||||
# Get title
|
||||
result = subprocess.run(
|
||||
[yt_dlp, '--dump-json', '--no-download', url],
|
||||
capture_output=True, text=True, timeout=30
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
print(f"Failed to get video info: {result.stderr}")
|
||||
return
|
||||
|
||||
info = json.loads(result.stdout)
|
||||
title = info.get('title', 'Unknown')
|
||||
duration = info.get('duration', 0)
|
||||
video_id = info.get('id', 'unknown')
|
||||
|
||||
# Download subtitles
|
||||
temp_dir = Path('/tmp/yt_subs')
|
||||
temp_dir.mkdir(exist_ok=True)
|
||||
|
||||
for f in temp_dir.glob('*'):
|
||||
f.unlink()
|
||||
|
||||
subprocess.run([
|
||||
yt_dlp, '--write-auto-subs', '--sub-langs', 'en',
|
||||
'--skip-download', '--sub-format', 'vtt',
|
||||
'-o', str(temp_dir / '%(id)s'),
|
||||
url
|
||||
], capture_output=True, timeout=120)
|
||||
|
||||
# Find and read subtitle file
|
||||
transcript = None
|
||||
for sub_file in temp_dir.glob('*.vtt'):
|
||||
content = sub_file.read_text(encoding='utf-8', errors='replace')
|
||||
transcript = clean_vtt(content)
|
||||
break
|
||||
|
||||
if not transcript:
|
||||
print("No subtitles found")
|
||||
return
|
||||
|
||||
# Create note filename
|
||||
date_str = datetime.now().strftime('%Y-%m-%d')
|
||||
slug = re.sub(r'[^\w\s-]', '', title.lower())[:50].strip().replace(' ', '-')
|
||||
filename = f"{date_str}_{slug}.md"
|
||||
|
||||
# Create simple note (without AI summary for now - just transcript)
|
||||
note_content = f"""# {title}
|
||||
|
||||
**Video:** {url}
|
||||
**Duration:** {duration // 60}:{duration % 60:02d}
|
||||
**Saved:** {date_str}
|
||||
**Tags:** #youtube #to-summarize
|
||||
|
||||
---
|
||||
|
||||
## Transcript
|
||||
|
||||
{transcript[:15000]}
|
||||
|
||||
---
|
||||
|
||||
*Notă: Sumarizarea va fi adăugată de Echo.*
|
||||
"""
|
||||
|
||||
# Save note
|
||||
NOTES_DIR.mkdir(parents=True, exist_ok=True)
|
||||
note_path = NOTES_DIR / filename
|
||||
note_path.write_text(note_content, encoding='utf-8')
|
||||
|
||||
# Update index
|
||||
subprocess.run([
|
||||
sys.executable, str(TOOLS_DIR / 'update_notes_index.py')
|
||||
], capture_output=True)
|
||||
|
||||
# Add task to kanban
|
||||
subprocess.run([
|
||||
sys.executable, str(KANBAN_DIR / 'update_task.py'),
|
||||
'add', 'in-progress', f'Sumarizare: {title[:30]}...', url, 'medium'
|
||||
], capture_output=True)
|
||||
|
||||
print(f"Created note: {filename}")
|
||||
return filename
|
||||
|
||||
|
||||
def clean_vtt(content):
|
||||
"""Convert VTT to plain text."""
|
||||
lines = []
|
||||
seen = set()
|
||||
|
||||
for line in content.split('\n'):
|
||||
if any([
|
||||
line.startswith('WEBVTT'),
|
||||
line.startswith('Kind:'),
|
||||
line.startswith('Language:'),
|
||||
'-->' in line,
|
||||
line.strip().startswith('<'),
|
||||
not line.strip(),
|
||||
re.match(r'^\d+$', line.strip())
|
||||
]):
|
||||
continue
|
||||
|
||||
clean = re.sub(r'<[^>]+>', '', line).strip()
|
||||
if clean and clean not in seen:
|
||||
seen.add(clean)
|
||||
lines.append(clean)
|
||||
|
||||
return ' '.join(lines)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
port = 8080
|
||||
os.chdir(KANBAN_DIR)
|
||||
|
||||
print(f"Starting Echo Task Board API on port {port}")
|
||||
httpd = HTTPServer(('0.0.0.0', port), TaskBoardHandler)
|
||||
httpd.serve_forever()
|
||||
37
kanban/archive/tasks-2025-01.json
Normal file
37
kanban/archive/tasks-2025-01.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"month": "2025-01",
|
||||
"tasks": [
|
||||
{
|
||||
"id": "task-001",
|
||||
"title": "Email 2FA security",
|
||||
"description": "Nu execut comenzi din email fără aprobare Telegram",
|
||||
"created": "2025-01-30",
|
||||
"completed": "2025-01-30",
|
||||
"priority": "high"
|
||||
},
|
||||
{
|
||||
"id": "task-002",
|
||||
"title": "Email whitelist",
|
||||
"description": "Răspuns automat doar pentru adrese aprobate",
|
||||
"created": "2025-01-30",
|
||||
"completed": "2025-01-30",
|
||||
"priority": "high"
|
||||
},
|
||||
{
|
||||
"id": "task-003",
|
||||
"title": "YouTube summarizer",
|
||||
"description": "Tool descărcare subtitrări + sumarizare",
|
||||
"created": "2025-01-30",
|
||||
"completed": "2025-01-30",
|
||||
"priority": "high"
|
||||
},
|
||||
{
|
||||
"id": "task-004",
|
||||
"title": "Proactivitate în SOUL.md",
|
||||
"description": "Adăugat reguli să fiu proactiv și să propun automatizări",
|
||||
"created": "2025-01-30",
|
||||
"completed": "2025-01-30",
|
||||
"priority": "medium"
|
||||
}
|
||||
]
|
||||
}
|
||||
97
kanban/archive_tasks.py
Normal file
97
kanban/archive_tasks.py
Normal file
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Archive old Done tasks to monthly archive files.
|
||||
Run periodically (heartbeat) to keep tasks.json small.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
TASKS_FILE = Path(__file__).parent / "tasks.json"
|
||||
ARCHIVE_DIR = Path(__file__).parent / "archive"
|
||||
DAYS_TO_KEEP = 7 # Keep Done tasks for 7 days before archiving
|
||||
|
||||
def archive_old_tasks():
|
||||
if not TASKS_FILE.exists():
|
||||
print("No tasks.json found")
|
||||
return
|
||||
|
||||
with open(TASKS_FILE, 'r') as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Find Done column
|
||||
done_col = None
|
||||
for col in data['columns']:
|
||||
if col['id'] == 'done':
|
||||
done_col = col
|
||||
break
|
||||
|
||||
if not done_col:
|
||||
print("No Done column found")
|
||||
return
|
||||
|
||||
# Calculate cutoff date
|
||||
cutoff = (datetime.now() - timedelta(days=DAYS_TO_KEEP)).strftime('%Y-%m-%d')
|
||||
|
||||
# Separate old and recent tasks
|
||||
old_tasks = []
|
||||
recent_tasks = []
|
||||
|
||||
for task in done_col['tasks']:
|
||||
completed = task.get('completed', task.get('created', ''))
|
||||
if completed and completed < cutoff:
|
||||
old_tasks.append(task)
|
||||
else:
|
||||
recent_tasks.append(task)
|
||||
|
||||
if not old_tasks:
|
||||
print(f"No tasks older than {DAYS_TO_KEEP} days to archive")
|
||||
return
|
||||
|
||||
# Create archive directory
|
||||
ARCHIVE_DIR.mkdir(exist_ok=True)
|
||||
|
||||
# Group old tasks by month
|
||||
by_month = {}
|
||||
for task in old_tasks:
|
||||
completed = task.get('completed', task.get('created', ''))[:7] # YYYY-MM
|
||||
if completed not in by_month:
|
||||
by_month[completed] = []
|
||||
by_month[completed].append(task)
|
||||
|
||||
# Write to monthly archive files
|
||||
for month, tasks in by_month.items():
|
||||
archive_file = ARCHIVE_DIR / f"tasks-{month}.json"
|
||||
|
||||
# Load existing archive
|
||||
if archive_file.exists():
|
||||
with open(archive_file, 'r') as f:
|
||||
archive = json.load(f)
|
||||
else:
|
||||
archive = {"month": month, "tasks": []}
|
||||
|
||||
# Add new tasks (avoid duplicates by ID)
|
||||
existing_ids = {t['id'] for t in archive['tasks']}
|
||||
for task in tasks:
|
||||
if task['id'] not in existing_ids:
|
||||
archive['tasks'].append(task)
|
||||
|
||||
# Save archive
|
||||
with open(archive_file, 'w') as f:
|
||||
json.dump(archive, f, indent=2, ensure_ascii=False)
|
||||
|
||||
print(f"Archived {len(tasks)} tasks to {archive_file.name}")
|
||||
|
||||
# Update tasks.json with only recent Done tasks
|
||||
done_col['tasks'] = recent_tasks
|
||||
data['lastUpdated'] = datetime.now().isoformat()
|
||||
|
||||
with open(TASKS_FILE, 'w') as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
|
||||
print(f"Kept {len(recent_tasks)} recent Done tasks, archived {len(old_tasks)}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
archive_old_tasks()
|
||||
438
kanban/common.css
Normal file
438
kanban/common.css
Normal file
@@ -0,0 +1,438 @@
|
||||
/*
|
||||
* Echo Design System
|
||||
* Modern, minimalist, unified UI
|
||||
*/
|
||||
|
||||
/* ============================================
|
||||
CSS Variables - Design Tokens
|
||||
============================================ */
|
||||
:root {
|
||||
/* Colors - Dark theme (high contrast) */
|
||||
--bg-base: #13131a;
|
||||
--bg-surface: rgba(255, 255, 255, 0.08);
|
||||
--bg-surface-hover: rgba(255, 255, 255, 0.12);
|
||||
--bg-surface-active: rgba(255, 255, 255, 0.16);
|
||||
--bg-elevated: rgba(255, 255, 255, 0.10);
|
||||
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: #d4d4d4;
|
||||
--text-muted: #a0a0a0;
|
||||
|
||||
--accent: #3b82f6;
|
||||
--accent-hover: #2563eb;
|
||||
--accent-subtle: rgba(59, 130, 246, 0.2);
|
||||
|
||||
--border: rgba(255, 255, 255, 0.2);
|
||||
--border-focus: rgba(59, 130, 246, 0.7);
|
||||
|
||||
/* Header specific */
|
||||
--header-bg: rgba(19, 19, 26, 0.95);
|
||||
|
||||
--success: #22c55e;
|
||||
--warning: #eab308;
|
||||
--error: #ef4444;
|
||||
|
||||
/* Spacing (8px grid) */
|
||||
--space-1: 4px;
|
||||
--space-2: 8px;
|
||||
--space-3: 12px;
|
||||
--space-4: 16px;
|
||||
--space-5: 20px;
|
||||
--space-6: 24px;
|
||||
--space-8: 32px;
|
||||
--space-10: 40px;
|
||||
|
||||
/* Typography */
|
||||
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Inter', sans-serif;
|
||||
--font-mono: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
|
||||
--text-xs: 0.75rem;
|
||||
--text-sm: 0.875rem;
|
||||
--text-base: 1rem;
|
||||
--text-lg: 1.125rem;
|
||||
--text-xl: 1.25rem;
|
||||
|
||||
/* Radius */
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 12px;
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.5);
|
||||
|
||||
/* Transitions */
|
||||
--transition-fast: 0.15s ease;
|
||||
--transition-base: 0.2s ease;
|
||||
}
|
||||
|
||||
/* Light theme */
|
||||
[data-theme="light"] {
|
||||
--bg-base: #f8f9fa;
|
||||
--bg-surface: rgba(0, 0, 0, 0.04);
|
||||
--bg-surface-hover: rgba(0, 0, 0, 0.08);
|
||||
--bg-surface-active: rgba(0, 0, 0, 0.12);
|
||||
--bg-elevated: rgba(0, 0, 0, 0.06);
|
||||
|
||||
--text-primary: #1a1a1a;
|
||||
--text-secondary: #444444;
|
||||
--text-muted: #666666;
|
||||
|
||||
--border: rgba(0, 0, 0, 0.12);
|
||||
--border-focus: rgba(59, 130, 246, 0.5);
|
||||
|
||||
--accent-subtle: rgba(59, 130, 246, 0.12);
|
||||
|
||||
/* Header light */
|
||||
--header-bg: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Reset & Base
|
||||
============================================ */
|
||||
*, *::before, *::after {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: 16px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
background: var(--bg-base);
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Header / Navigation
|
||||
============================================ */
|
||||
.header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
background: var(--header-bg);
|
||||
backdrop-filter: blur(12px);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: var(--space-3) var(--space-5);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.logo svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-4);
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
border-radius: var(--radius-md);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-surface-hover);
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
color: var(--text-primary);
|
||||
background: var(--accent-subtle);
|
||||
}
|
||||
|
||||
.nav-item svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
/* Theme toggle */
|
||||
.theme-toggle {
|
||||
padding: var(--space-2);
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-md);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.theme-toggle:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-surface-hover);
|
||||
}
|
||||
|
||||
.theme-toggle svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Cards
|
||||
============================================ */
|
||||
.card {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-4);
|
||||
transition: all var(--transition-base);
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
background: var(--bg-surface-hover);
|
||||
border-color: var(--border-focus);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: var(--text-base);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Buttons
|
||||
============================================ */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-4);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--bg-surface-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.btn-ghost:hover {
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Inputs
|
||||
============================================ */
|
||||
.input {
|
||||
width: 100%;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-primary);
|
||||
font-size: var(--text-sm);
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
transition: border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Tags / Badges
|
||||
============================================ */
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-surface);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 20px;
|
||||
padding: 2px 6px;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-surface-active);
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Grid Layouts
|
||||
============================================ */
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.grid-2 { grid-template-columns: repeat(2, 1fr); }
|
||||
.grid-3 { grid-template-columns: repeat(3, 1fr); }
|
||||
.grid-auto { grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); }
|
||||
|
||||
/* ============================================
|
||||
Status Colors
|
||||
============================================ */
|
||||
.status-success { color: var(--success); }
|
||||
.status-warning { color: var(--warning); }
|
||||
.status-error { color: var(--error); }
|
||||
|
||||
/* ============================================
|
||||
Scrollbar
|
||||
============================================ */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--bg-surface-active);
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Responsive
|
||||
============================================ */
|
||||
@media (max-width: 768px) {
|
||||
/* Larger base font for mobile readability */
|
||||
html {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
.nav-item span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
padding: var(--space-2);
|
||||
}
|
||||
|
||||
.grid-2, .grid-3 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
/* Larger touch targets */
|
||||
.btn, .input, .tag {
|
||||
min-height: 44px;
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: var(--space-5);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Utilities
|
||||
============================================ */
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.flex { display: flex; }
|
||||
.flex-col { flex-direction: column; }
|
||||
.items-center { align-items: center; }
|
||||
.justify-between { justify-content: space-between; }
|
||||
.gap-2 { gap: var(--space-2); }
|
||||
.gap-4 { gap: var(--space-4); }
|
||||
16
kanban/echo-taskboard.service
Normal file
16
kanban/echo-taskboard.service
Normal file
@@ -0,0 +1,16 @@
|
||||
[Unit]
|
||||
Description=Echo Task Board API
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=moltbot
|
||||
WorkingDirectory=/home/moltbot/clawd/kanban
|
||||
ExecStart=/usr/bin/python3 /home/moltbot/clawd/kanban/api.py
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
StandardOutput=append:/var/log/echo-taskboard.log
|
||||
StandardError=append:/var/log/echo-taskboard.log
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
633
kanban/files.html
Normal file
633
kanban/files.html
Normal file
@@ -0,0 +1,633 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ro">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Echo · Files</title>
|
||||
<link rel="stylesheet" href="common.css">
|
||||
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
|
||||
<script src="swipe-nav.js"></script>
|
||||
<style>
|
||||
.main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 52px);
|
||||
}
|
||||
|
||||
/* Toolbar */
|
||||
.toolbar {
|
||||
padding: var(--space-3) var(--space-5);
|
||||
background: var(--bg-surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-muted);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.breadcrumb-item {
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border-radius: var(--radius-sm);
|
||||
transition: all var(--transition-fast);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.breadcrumb-item:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-surface-hover);
|
||||
}
|
||||
|
||||
.breadcrumb-item.current {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.breadcrumb-sep {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.breadcrumb-sep svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.toolbar-actions {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
/* View modes */
|
||||
.view-toggle {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
background: var(--bg-base);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.view-btn {
|
||||
padding: var(--space-2);
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.view-btn:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.view-btn.active {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.view-btn svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
/* Content area */
|
||||
.content-area {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Browse panel */
|
||||
.browse-panel {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: var(--space-5);
|
||||
}
|
||||
|
||||
.browse-panel.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.file-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.file-item {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-4);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.file-item:hover {
|
||||
border-color: var(--accent);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.file-item.active {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-subtle);
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.file-icon svg {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.file-icon.folder svg {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.file-name {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-primary);
|
||||
word-break: break-word;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.file-size {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Editor panel */
|
||||
.editor-panel {
|
||||
flex: 1;
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
background: var(--bg-base);
|
||||
}
|
||||
|
||||
.editor-panel.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.editor-header {
|
||||
padding: var(--space-3) var(--space-5);
|
||||
background: var(--bg-surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.editor-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
color: var(--text-primary);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.editor-title svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.editor-actions {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.editor-body {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#codeEditor {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
border: none;
|
||||
padding: var(--space-5);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
resize: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.editor-footer {
|
||||
padding: var(--space-2) var(--space-5);
|
||||
background: var(--bg-surface);
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.status-saved { color: var(--success); }
|
||||
.status-modified { color: var(--warning); }
|
||||
.status-error { color: var(--error); }
|
||||
|
||||
/* Empty state */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--text-muted);
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.empty-state svg {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.toolbar {
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
.file-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="header">
|
||||
<a href="index.html" class="logo">
|
||||
<i data-lucide="circle-dot"></i>
|
||||
Echo
|
||||
</a>
|
||||
<nav class="nav">
|
||||
<a href="index.html" class="nav-item">
|
||||
<i data-lucide="layout-list"></i>
|
||||
<span>Tasks</span>
|
||||
</a>
|
||||
<a href="notes.html" class="nav-item">
|
||||
<i data-lucide="file-text"></i>
|
||||
<span>Notes</span>
|
||||
</a>
|
||||
<a href="files.html" class="nav-item active">
|
||||
<i data-lucide="folder"></i>
|
||||
<span>Files</span>
|
||||
</a>
|
||||
<button class="theme-toggle" onclick="toggleTheme()" title="Schimbă tema">
|
||||
<i data-lucide="sun" id="themeIcon"></i>
|
||||
</button>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="main">
|
||||
<div class="toolbar">
|
||||
<div class="breadcrumb" id="breadcrumb">
|
||||
<span class="breadcrumb-item current" onclick="loadPath('')">~/clawd</span>
|
||||
</div>
|
||||
<div class="toolbar-actions">
|
||||
<div class="view-toggle">
|
||||
<button class="view-btn active" id="browseBtn" onclick="showBrowse()" title="Browse">
|
||||
<i data-lucide="layout-grid"></i>
|
||||
</button>
|
||||
<button class="view-btn" id="editorBtn" onclick="showEditor()" title="Editor">
|
||||
<i data-lucide="code"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content-area">
|
||||
<div class="browse-panel" id="browsePanel">
|
||||
<div class="file-grid" id="fileGrid">
|
||||
<div class="empty-state">
|
||||
<i data-lucide="loader"></i>
|
||||
<p>Se încarcă...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="editor-panel" id="editorPanel">
|
||||
<div class="editor-header">
|
||||
<div class="editor-title">
|
||||
<i data-lucide="file"></i>
|
||||
<span id="editorFileName">Niciun fișier</span>
|
||||
</div>
|
||||
<div class="editor-actions">
|
||||
<button class="btn btn-ghost" onclick="showBrowse()" title="Înapoi">
|
||||
<i data-lucide="arrow-left"></i>
|
||||
</button>
|
||||
<button class="btn btn-ghost" onclick="reloadFile()" id="reloadBtn" disabled title="Reload">
|
||||
<i data-lucide="refresh-cw"></i>
|
||||
</button>
|
||||
<button class="btn btn-primary" onclick="saveFile()" id="saveBtn" disabled>
|
||||
<i data-lucide="save"></i>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="editor-body">
|
||||
<textarea id="codeEditor" placeholder="Selectează un fișier..."></textarea>
|
||||
</div>
|
||||
<div class="editor-footer">
|
||||
<span id="statusText">Ready</span>
|
||||
<span id="fileInfo"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
// Theme
|
||||
function initTheme() {
|
||||
const saved = localStorage.getItem('theme') || 'dark';
|
||||
document.documentElement.setAttribute('data-theme', saved);
|
||||
updateThemeIcon(saved);
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
const current = document.documentElement.getAttribute('data-theme') || 'dark';
|
||||
const next = current === 'dark' ? 'light' : 'dark';
|
||||
document.documentElement.setAttribute('data-theme', next);
|
||||
localStorage.setItem('theme', next);
|
||||
updateThemeIcon(next);
|
||||
}
|
||||
|
||||
function updateThemeIcon(theme) {
|
||||
const icon = document.getElementById('themeIcon');
|
||||
if (icon) {
|
||||
icon.setAttribute('data-lucide', theme === 'dark' ? 'sun' : 'moon');
|
||||
lucide.createIcons();
|
||||
}
|
||||
}
|
||||
|
||||
initTheme();
|
||||
lucide.createIcons();
|
||||
|
||||
const API_BASE = window.location.pathname.includes('/echo/') ? '/echo' : '';
|
||||
let currentPath = '';
|
||||
let currentFile = null;
|
||||
let originalContent = '';
|
||||
let isModified = false;
|
||||
|
||||
function showBrowse() {
|
||||
if (isModified && !confirm('Ai modificări nesalvate. Continui?')) return;
|
||||
document.getElementById('browsePanel').classList.remove('hidden');
|
||||
document.getElementById('editorPanel').classList.remove('active');
|
||||
document.getElementById('browseBtn').classList.add('active');
|
||||
document.getElementById('editorBtn').classList.remove('active');
|
||||
}
|
||||
|
||||
function showEditor() {
|
||||
document.getElementById('browsePanel').classList.add('hidden');
|
||||
document.getElementById('editorPanel').classList.add('active');
|
||||
document.getElementById('browseBtn').classList.remove('active');
|
||||
document.getElementById('editorBtn').classList.add('active');
|
||||
}
|
||||
|
||||
async function loadPath(path = '') {
|
||||
currentPath = path;
|
||||
updateBreadcrumb();
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/files?path=${encodeURIComponent(path)}&action=list`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error) {
|
||||
showError(data.error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.type === 'dir') {
|
||||
renderFileGrid(data.items);
|
||||
updateURL(path);
|
||||
} else if (data.type === 'file') {
|
||||
openFile(path, data);
|
||||
}
|
||||
} catch (e) {
|
||||
showError('Eroare: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function updateBreadcrumb() {
|
||||
const breadcrumb = document.getElementById('breadcrumb');
|
||||
const parts = currentPath.split('/').filter(p => p);
|
||||
|
||||
let html = `<span class="breadcrumb-item ${parts.length === 0 ? 'current' : ''}" onclick="loadPath('')">~/clawd</span>`;
|
||||
let buildPath = '';
|
||||
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const part = parts[i];
|
||||
buildPath += (buildPath ? '/' : '') + part;
|
||||
const p = buildPath;
|
||||
const isCurrent = i === parts.length - 1;
|
||||
html += `
|
||||
<span class="breadcrumb-sep"><i data-lucide="chevron-right"></i></span>
|
||||
<span class="breadcrumb-item ${isCurrent ? 'current' : ''}" onclick="loadPath('${p}')">${part}</span>
|
||||
`;
|
||||
}
|
||||
|
||||
breadcrumb.innerHTML = html;
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
||||
function renderFileGrid(items) {
|
||||
const grid = document.getElementById('fileGrid');
|
||||
|
||||
if (items.length === 0) {
|
||||
grid.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<i data-lucide="folder-open"></i>
|
||||
<p>Folder gol</p>
|
||||
</div>
|
||||
`;
|
||||
lucide.createIcons();
|
||||
return;
|
||||
}
|
||||
|
||||
items.sort((a, b) => {
|
||||
if (a.type !== b.type) return a.type === 'dir' ? -1 : 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
grid.innerHTML = items.map(item => `
|
||||
<div class="file-item ${currentFile === item.path ? 'active' : ''}" onclick="handleClick('${item.path}', '${item.type}')">
|
||||
<div class="file-icon ${item.type === 'dir' ? 'folder' : ''}">
|
||||
<i data-lucide="${item.type === 'dir' ? 'folder' : getFileIcon(item.name)}"></i>
|
||||
</div>
|
||||
<div class="file-name">${item.name}</div>
|
||||
${item.size ? `<div class="file-size">${formatSize(item.size)}</div>` : ''}
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
||||
function getFileIcon(name) {
|
||||
const ext = name.split('.').pop().toLowerCase();
|
||||
const icons = {
|
||||
'md': 'file-text',
|
||||
'txt': 'file-text',
|
||||
'json': 'file-json',
|
||||
'js': 'file-code',
|
||||
'py': 'file-code',
|
||||
'html': 'file-code',
|
||||
'css': 'file-code',
|
||||
'sh': 'terminal',
|
||||
'yml': 'file-cog',
|
||||
'yaml': 'file-cog',
|
||||
'log': 'file-text',
|
||||
'xsd': 'file-code',
|
||||
'pdf': 'file-text'
|
||||
};
|
||||
return icons[ext] || 'file';
|
||||
}
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
}
|
||||
|
||||
function handleClick(path, type) {
|
||||
if (type === 'dir') {
|
||||
loadPath(path);
|
||||
} else {
|
||||
if (isModified && !confirm('Ai modificări nesalvate. Continui?')) return;
|
||||
loadPath(path);
|
||||
}
|
||||
}
|
||||
|
||||
function openFile(path, data) {
|
||||
currentFile = path;
|
||||
originalContent = data.content;
|
||||
updateURL(path);
|
||||
|
||||
document.getElementById('editorFileName').textContent = data.name;
|
||||
document.getElementById('codeEditor').value = data.content;
|
||||
document.getElementById('saveBtn').disabled = false;
|
||||
document.getElementById('reloadBtn').disabled = false;
|
||||
document.getElementById('fileInfo').textContent = formatSize(data.size);
|
||||
|
||||
if (data.truncated) {
|
||||
setStatus('Fișier trunchiat', 'error');
|
||||
} else {
|
||||
setStatus('Loaded', 'saved');
|
||||
}
|
||||
|
||||
isModified = false;
|
||||
showEditor();
|
||||
}
|
||||
|
||||
async function saveFile() {
|
||||
if (!currentFile) return;
|
||||
|
||||
const content = document.getElementById('codeEditor').value;
|
||||
|
||||
try {
|
||||
document.getElementById('saveBtn').disabled = true;
|
||||
setStatus('Se salvează...', 'modified');
|
||||
|
||||
const response = await fetch(`${API_BASE}/api/files`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: currentFile, content })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error) {
|
||||
setStatus('Eroare: ' + data.error, 'error');
|
||||
} else {
|
||||
originalContent = content;
|
||||
isModified = false;
|
||||
setStatus('Salvat ✓', 'saved');
|
||||
}
|
||||
} catch (e) {
|
||||
setStatus('Eroare: ' + e.message, 'error');
|
||||
}
|
||||
|
||||
document.getElementById('saveBtn').disabled = false;
|
||||
}
|
||||
|
||||
function reloadFile() {
|
||||
if (currentFile && confirm('Renunți la modificări?')) {
|
||||
loadPath(currentFile);
|
||||
}
|
||||
}
|
||||
|
||||
function setStatus(text, type) {
|
||||
const status = document.getElementById('statusText');
|
||||
status.textContent = text;
|
||||
status.className = 'status-' + type;
|
||||
}
|
||||
|
||||
function showError(msg) {
|
||||
document.getElementById('fileGrid').innerHTML = `
|
||||
<div class="empty-state">
|
||||
<i data-lucide="alert-circle"></i>
|
||||
<p style="color: var(--error)">${msg}</p>
|
||||
</div>
|
||||
`;
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
||||
function updateURL(path) {
|
||||
if (path) {
|
||||
history.replaceState(null, '', `#${path}`);
|
||||
} else {
|
||||
history.replaceState(null, '', window.location.pathname);
|
||||
}
|
||||
}
|
||||
|
||||
function getPathFromURL() {
|
||||
const hash = window.location.hash;
|
||||
return hash ? decodeURIComponent(hash.slice(1)) : '';
|
||||
}
|
||||
|
||||
window.addEventListener('hashchange', () => loadPath(getPathFromURL()));
|
||||
|
||||
document.getElementById('codeEditor').addEventListener('input', function() {
|
||||
isModified = this.value !== originalContent;
|
||||
setStatus(isModified ? 'Modified' : 'Ready', isModified ? 'modified' : 'saved');
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.ctrlKey && e.key === 's') {
|
||||
e.preventDefault();
|
||||
saveFile();
|
||||
}
|
||||
});
|
||||
|
||||
// Init
|
||||
loadPath(getPathFromURL());
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
725
kanban/index.html
Normal file
725
kanban/index.html
Normal file
@@ -0,0 +1,725 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ro">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Echo · Tasks</title>
|
||||
<link rel="stylesheet" href="common.css">
|
||||
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
|
||||
<script src="swipe-nav.js"></script>
|
||||
<style>
|
||||
/* Page-specific styles */
|
||||
.main {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: var(--space-5);
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
max-width: 400px;
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
/* Kanban board */
|
||||
.board {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
gap: var(--space-5);
|
||||
}
|
||||
|
||||
.column {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.column-header {
|
||||
padding: var(--space-4);
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.column-header:hover {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
/* Column header colors */
|
||||
.column.backlog .column-header {
|
||||
background: linear-gradient(135deg, rgba(100, 100, 120, 0.3), rgba(80, 80, 100, 0.2));
|
||||
border-left: 3px solid #6b7280;
|
||||
}
|
||||
|
||||
.column.in-progress .column-header {
|
||||
background: linear-gradient(135deg, rgba(59, 130, 246, 0.25), rgba(37, 99, 235, 0.15));
|
||||
border-left: 3px solid #3b82f6;
|
||||
}
|
||||
|
||||
.column.done .column-header {
|
||||
background: linear-gradient(135deg, rgba(34, 197, 94, 0.25), rgba(22, 163, 74, 0.15));
|
||||
border-left: 3px solid #22c55e;
|
||||
}
|
||||
|
||||
.column-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.column-title svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--text-muted);
|
||||
transition: transform var(--transition-fast);
|
||||
}
|
||||
|
||||
.column.collapsed .column-title svg {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.column-body {
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
.column.collapsed .column-body {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.column.collapsed {
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
/* Tasks */
|
||||
.task {
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-3);
|
||||
margin-bottom: var(--space-2);
|
||||
cursor: grab;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.task:hover {
|
||||
border-color: var(--border-focus);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.task.dragging {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.task-title {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.task-desc {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
display: none;
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.task.expanded .task-desc {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.task-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.task-date {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.task-priority {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.priority-high { background: var(--error); }
|
||||
.priority-medium { background: var(--warning); }
|
||||
.priority-low { background: var(--success); }
|
||||
|
||||
.task-actions {
|
||||
display: none;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.task:hover .task-actions {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.task-action {
|
||||
padding: var(--space-1);
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.task-action:hover {
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.task-action svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
/* Add task */
|
||||
.add-task-btn {
|
||||
width: 100%;
|
||||
padding: var(--space-3);
|
||||
background: transparent;
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-sm);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.add-task-btn:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
background: var(--accent-subtle);
|
||||
}
|
||||
|
||||
.add-task-btn svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.add-form {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3);
|
||||
background: var(--bg-elevated);
|
||||
border-radius: var(--radius-md);
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.add-form.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.add-form-actions {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
/* Stats */
|
||||
.stats {
|
||||
display: flex;
|
||||
gap: var(--space-6);
|
||||
padding: var(--space-5);
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
margin-top: var(--space-6);
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.stat {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Drag over */
|
||||
.column.drag-over {
|
||||
background: var(--accent-subtle);
|
||||
}
|
||||
|
||||
/* Toast */
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: var(--space-5);
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(100px);
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
padding: var(--space-3) var(--space-5);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-sm);
|
||||
opacity: 0;
|
||||
transition: all var(--transition-base);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.toast.show {
|
||||
transform: translateX(-50%) translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Hidden on search */
|
||||
.task.hidden { display: none; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.board {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.stats {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="header">
|
||||
<a href="index.html" class="logo">
|
||||
<i data-lucide="circle-dot"></i>
|
||||
Echo
|
||||
</a>
|
||||
<nav class="nav">
|
||||
<a href="index.html" class="nav-item active">
|
||||
<i data-lucide="layout-list"></i>
|
||||
<span>Tasks</span>
|
||||
</a>
|
||||
<a href="notes.html" class="nav-item">
|
||||
<i data-lucide="file-text"></i>
|
||||
<span>Notes</span>
|
||||
</a>
|
||||
<a href="files.html" class="nav-item">
|
||||
<i data-lucide="folder"></i>
|
||||
<span>Files</span>
|
||||
</a>
|
||||
<button class="theme-toggle" onclick="toggleTheme()" title="Schimbă tema">
|
||||
<i data-lucide="sun" id="themeIcon"></i>
|
||||
</button>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="main">
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">Task Board</h1>
|
||||
<p class="page-subtitle" id="lastUpdated">Se încarcă...</p>
|
||||
</div>
|
||||
|
||||
<div class="search-bar">
|
||||
<input type="text" class="input" id="searchInput" placeholder="Caută task-uri..." oninput="searchTasks()">
|
||||
</div>
|
||||
|
||||
<div class="board" id="board"></div>
|
||||
|
||||
<div class="stats" id="stats"></div>
|
||||
</main>
|
||||
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<script>
|
||||
// Theme
|
||||
function initTheme() {
|
||||
const saved = localStorage.getItem('theme') || 'dark';
|
||||
document.documentElement.setAttribute('data-theme', saved);
|
||||
updateThemeIcon(saved);
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
const current = document.documentElement.getAttribute('data-theme') || 'dark';
|
||||
const next = current === 'dark' ? 'light' : 'dark';
|
||||
document.documentElement.setAttribute('data-theme', next);
|
||||
localStorage.setItem('theme', next);
|
||||
updateThemeIcon(next);
|
||||
}
|
||||
|
||||
function updateThemeIcon(theme) {
|
||||
const icon = document.getElementById('themeIcon');
|
||||
if (icon) {
|
||||
icon.setAttribute('data-lucide', theme === 'dark' ? 'sun' : 'moon');
|
||||
lucide.createIcons();
|
||||
}
|
||||
}
|
||||
|
||||
initTheme();
|
||||
|
||||
// Init Lucide icons
|
||||
lucide.createIcons();
|
||||
|
||||
let tasksData = null;
|
||||
let draggedTask = null;
|
||||
let saveTimeout = null;
|
||||
const DONE_LIMIT = 5;
|
||||
let showAllDone = false;
|
||||
|
||||
// Collapsed columns state - default: only in-progress expanded
|
||||
function getCollapsedColumns() {
|
||||
try {
|
||||
const saved = localStorage.getItem('collapsedColumns');
|
||||
if (saved) return JSON.parse(saved);
|
||||
return ['backlog', 'done'];
|
||||
} catch { return ['backlog', 'done']; }
|
||||
}
|
||||
|
||||
function setCollapsedColumns(cols) {
|
||||
localStorage.setItem('collapsedColumns', JSON.stringify(cols));
|
||||
}
|
||||
|
||||
function toggleColumnCollapse(columnId) {
|
||||
const cols = getCollapsedColumns();
|
||||
const idx = cols.indexOf(columnId);
|
||||
if (idx > -1) cols.splice(idx, 1);
|
||||
else cols.push(columnId);
|
||||
setCollapsedColumns(cols);
|
||||
renderBoard();
|
||||
}
|
||||
|
||||
function toggleTaskExpand(e, taskEl) {
|
||||
if (e.target.hasAttribute('contenteditable') || e.target.tagName === 'BUTTON') return;
|
||||
taskEl.classList.toggle('expanded');
|
||||
}
|
||||
|
||||
async function loadTasks() {
|
||||
try {
|
||||
const response = await fetch('tasks.json?' + Date.now());
|
||||
tasksData = await response.json();
|
||||
renderBoard();
|
||||
} catch (error) {
|
||||
showToast('Eroare la încărcare', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function searchTasks() {
|
||||
const query = document.getElementById('searchInput').value.toLowerCase().trim();
|
||||
document.querySelectorAll('.task').forEach(taskEl => {
|
||||
const title = taskEl.querySelector('.task-title')?.textContent.toLowerCase() || '';
|
||||
const desc = taskEl.querySelector('.task-desc')?.textContent.toLowerCase() || '';
|
||||
taskEl.classList.toggle('hidden', query && !title.includes(query) && !desc.includes(query));
|
||||
});
|
||||
}
|
||||
|
||||
function renderBoard() {
|
||||
const board = document.getElementById('board');
|
||||
const stats = document.getElementById('stats');
|
||||
const lastUpdated = document.getElementById('lastUpdated');
|
||||
|
||||
const date = new Date(tasksData.lastUpdated);
|
||||
lastUpdated.textContent = `Actualizat: ${date.toLocaleString('ro-RO')}`;
|
||||
|
||||
board.innerHTML = '';
|
||||
|
||||
let totalTasks = 0, doneTasks = 0, inProgress = 0;
|
||||
|
||||
const icons = {
|
||||
'backlog': 'inbox',
|
||||
'in-progress': 'loader',
|
||||
'done': 'check-circle'
|
||||
};
|
||||
|
||||
tasksData.columns.forEach(column => {
|
||||
column.tasks.sort((a, b) => {
|
||||
const dateA = a.completed || a.created || '';
|
||||
const dateB = b.completed || b.created || '';
|
||||
return dateB.localeCompare(dateA);
|
||||
});
|
||||
|
||||
totalTasks += column.tasks.length;
|
||||
if (column.id === 'done') doneTasks = column.tasks.length;
|
||||
if (column.id === 'in-progress') inProgress = column.tasks.length;
|
||||
|
||||
const isCollapsed = getCollapsedColumns().includes(column.id);
|
||||
|
||||
let visibleTasks = column.tasks;
|
||||
let hasMore = false;
|
||||
if (column.id === 'done' && !showAllDone && column.tasks.length > DONE_LIMIT) {
|
||||
visibleTasks = column.tasks.slice(0, DONE_LIMIT);
|
||||
hasMore = true;
|
||||
}
|
||||
|
||||
const columnEl = document.createElement('div');
|
||||
columnEl.className = `column ${column.id}${isCollapsed ? ' collapsed' : ''}`;
|
||||
columnEl.dataset.columnId = column.id;
|
||||
|
||||
columnEl.innerHTML = `
|
||||
<div class="column-header" onclick="toggleColumnCollapse('${column.id}')">
|
||||
<div class="column-title">
|
||||
<i data-lucide="chevron-down"></i>
|
||||
${column.name}
|
||||
</div>
|
||||
<span class="badge">${column.tasks.length}</span>
|
||||
</div>
|
||||
<div class="column-body">
|
||||
<div class="tasks-container" data-column-id="${column.id}">
|
||||
${visibleTasks.map(task => renderTask(task)).join('')}
|
||||
</div>
|
||||
${hasMore ? `
|
||||
<button class="add-task-btn" onclick="event.stopPropagation(); showAllDone=true; renderBoard();">
|
||||
<i data-lucide="chevron-down"></i>
|
||||
${column.tasks.length - DONE_LIMIT} mai multe
|
||||
</button>
|
||||
` : ''}
|
||||
${column.id === 'done' && showAllDone && column.tasks.length > DONE_LIMIT ? `
|
||||
<button class="add-task-btn" onclick="event.stopPropagation(); showAllDone=false; renderBoard();">
|
||||
<i data-lucide="chevron-up"></i>
|
||||
Arată mai puține
|
||||
</button>
|
||||
` : ''}
|
||||
<button class="add-task-btn" onclick="event.stopPropagation(); showAddForm('${column.id}')">
|
||||
<i data-lucide="plus"></i>
|
||||
Adaugă task
|
||||
</button>
|
||||
<div class="add-form" id="form-${column.id}">
|
||||
<input type="text" class="input" placeholder="Titlu..." id="title-${column.id}">
|
||||
<textarea class="input" placeholder="Descriere..." id="desc-${column.id}" rows="2"></textarea>
|
||||
<select class="input" id="priority-${column.id}">
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">High</option>
|
||||
<option value="low">Low</option>
|
||||
</select>
|
||||
<div class="add-form-actions">
|
||||
<button class="btn btn-secondary" onclick="hideAddForm('${column.id}')">Anulează</button>
|
||||
<button class="btn btn-primary" onclick="addTask('${column.id}')">Adaugă</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
board.appendChild(columnEl);
|
||||
|
||||
const container = columnEl.querySelector('.tasks-container');
|
||||
container.addEventListener('dragover', handleDragOver);
|
||||
container.addEventListener('drop', handleDrop);
|
||||
container.addEventListener('dragleave', handleDragLeave);
|
||||
});
|
||||
|
||||
document.querySelectorAll('.task').forEach(task => {
|
||||
task.addEventListener('dragstart', handleDragStart);
|
||||
task.addEventListener('dragend', handleDragEnd);
|
||||
});
|
||||
|
||||
stats.innerHTML = `
|
||||
<div class="stat"><div class="stat-value">${totalTasks}</div><div class="stat-label">Total</div></div>
|
||||
<div class="stat"><div class="stat-value">${inProgress}</div><div class="stat-label">În progres</div></div>
|
||||
<div class="stat"><div class="stat-value">${doneTasks}</div><div class="stat-label">Finalizate</div></div>
|
||||
`;
|
||||
|
||||
lucide.createIcons();
|
||||
searchTasks();
|
||||
}
|
||||
|
||||
function renderTask(task) {
|
||||
const hasDesc = task.description && task.description.trim();
|
||||
return `
|
||||
<div class="task" draggable="true" data-task-id="${task.id}" onclick="toggleTaskExpand(event, this)">
|
||||
<div class="task-title" contenteditable="true" onblur="updateTask('${task.id}', 'title', this.textContent)">${task.title}</div>
|
||||
${hasDesc ? `<div class="task-desc" contenteditable="true" onblur="updateTask('${task.id}', 'description', this.textContent)">${task.description}</div>` : ''}
|
||||
<div class="task-footer">
|
||||
<span class="task-date">${task.completed || task.created}</span>
|
||||
<div class="task-actions">
|
||||
<button class="task-action" onclick="event.stopPropagation(); cyclePriority('${task.id}')" title="Prioritate">
|
||||
<i data-lucide="flag"></i>
|
||||
</button>
|
||||
<button class="task-action" onclick="event.stopPropagation(); deleteTask('${task.id}')" title="Șterge">
|
||||
<i data-lucide="trash-2"></i>
|
||||
</button>
|
||||
</div>
|
||||
<span class="task-priority priority-${task.priority || 'medium'}" title="${task.priority || 'medium'}"></span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Drag & Drop
|
||||
function handleDragStart(e) {
|
||||
draggedTask = e.target;
|
||||
e.target.classList.add('dragging');
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
}
|
||||
|
||||
function handleDragEnd(e) {
|
||||
e.target.classList.remove('dragging');
|
||||
document.querySelectorAll('.column').forEach(col => col.classList.remove('drag-over'));
|
||||
}
|
||||
|
||||
function handleDragOver(e) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
e.currentTarget.closest('.column').classList.add('drag-over');
|
||||
}
|
||||
|
||||
function handleDragLeave(e) {
|
||||
e.currentTarget.closest('.column').classList.remove('drag-over');
|
||||
}
|
||||
|
||||
function handleDrop(e) {
|
||||
e.preventDefault();
|
||||
const targetColumn = e.currentTarget.dataset.columnId;
|
||||
const taskId = draggedTask.dataset.taskId;
|
||||
|
||||
for (const col of tasksData.columns) {
|
||||
const taskIdx = col.tasks.findIndex(t => t.id === taskId);
|
||||
if (taskIdx > -1) {
|
||||
const [task] = col.tasks.splice(taskIdx, 1);
|
||||
if (targetColumn === 'done' && !task.completed) {
|
||||
task.completed = new Date().toISOString().split('T')[0];
|
||||
} else if (targetColumn !== 'done') {
|
||||
delete task.completed;
|
||||
}
|
||||
const targetCol = tasksData.columns.find(c => c.id === targetColumn);
|
||||
targetCol.tasks.unshift(task);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
renderBoard();
|
||||
scheduleSave();
|
||||
}
|
||||
|
||||
function showAddForm(columnId) {
|
||||
document.querySelectorAll('.add-form').forEach(f => f.classList.remove('active'));
|
||||
document.getElementById(`form-${columnId}`).classList.add('active');
|
||||
document.getElementById(`title-${columnId}`).focus();
|
||||
}
|
||||
|
||||
function hideAddForm(columnId) {
|
||||
document.getElementById(`form-${columnId}`).classList.remove('active');
|
||||
}
|
||||
|
||||
function addTask(columnId) {
|
||||
const title = document.getElementById(`title-${columnId}`).value.trim();
|
||||
if (!title) return;
|
||||
|
||||
const desc = document.getElementById(`desc-${columnId}`).value.trim();
|
||||
const priority = document.getElementById(`priority-${columnId}`).value;
|
||||
|
||||
const newTask = {
|
||||
id: 'task-' + Date.now(),
|
||||
title,
|
||||
description: desc,
|
||||
priority,
|
||||
created: new Date().toISOString().split('T')[0]
|
||||
};
|
||||
|
||||
const col = tasksData.columns.find(c => c.id === columnId);
|
||||
col.tasks.unshift(newTask);
|
||||
|
||||
hideAddForm(columnId);
|
||||
document.getElementById(`title-${columnId}`).value = '';
|
||||
document.getElementById(`desc-${columnId}`).value = '';
|
||||
|
||||
renderBoard();
|
||||
scheduleSave();
|
||||
showToast('Task adăugat');
|
||||
}
|
||||
|
||||
function updateTask(taskId, field, value) {
|
||||
for (const col of tasksData.columns) {
|
||||
const task = col.tasks.find(t => t.id === taskId);
|
||||
if (task) {
|
||||
task[field] = value.trim();
|
||||
break;
|
||||
}
|
||||
}
|
||||
scheduleSave();
|
||||
}
|
||||
|
||||
function deleteTask(taskId) {
|
||||
if (!confirm('Ștergi acest task?')) return;
|
||||
for (const col of tasksData.columns) {
|
||||
const idx = col.tasks.findIndex(t => t.id === taskId);
|
||||
if (idx > -1) {
|
||||
col.tasks.splice(idx, 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
renderBoard();
|
||||
scheduleSave();
|
||||
showToast('Task șters');
|
||||
}
|
||||
|
||||
function cyclePriority(taskId) {
|
||||
const priorities = ['low', 'medium', 'high'];
|
||||
for (const col of tasksData.columns) {
|
||||
const task = col.tasks.find(t => t.id === taskId);
|
||||
if (task) {
|
||||
const current = task.priority || 'medium';
|
||||
const idx = priorities.indexOf(current);
|
||||
task.priority = priorities[(idx + 1) % 3];
|
||||
break;
|
||||
}
|
||||
}
|
||||
renderBoard();
|
||||
scheduleSave();
|
||||
}
|
||||
|
||||
function scheduleSave() {
|
||||
if (saveTimeout) clearTimeout(saveTimeout);
|
||||
saveTimeout = setTimeout(saveTasks, 1000);
|
||||
}
|
||||
|
||||
async function saveTasks() {
|
||||
tasksData.lastUpdated = new Date().toISOString();
|
||||
try {
|
||||
const response = await fetch('/echo/api/files', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
path: 'kanban/tasks.json',
|
||||
content: JSON.stringify(tasksData, null, 2)
|
||||
})
|
||||
});
|
||||
if (!response.ok) throw new Error('Save failed');
|
||||
} catch (error) {
|
||||
showToast('Eroare la salvare', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function showToast(message, type = 'success') {
|
||||
const toast = document.getElementById('toast');
|
||||
toast.textContent = message;
|
||||
toast.className = `toast show status-${type}`;
|
||||
setTimeout(() => toast.classList.remove('show'), 3000);
|
||||
}
|
||||
|
||||
loadTasks();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
772
kanban/notes.html
Normal file
772
kanban/notes.html
Normal file
@@ -0,0 +1,772 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ro">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Echo · Notes</title>
|
||||
<link rel="stylesheet" href="common.css">
|
||||
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
|
||||
<script src="swipe-nav.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||
<style>
|
||||
.main {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: var(--space-5);
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--space-6);
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
width: 300px;
|
||||
}
|
||||
|
||||
/* Date sections (accordion) */
|
||||
.date-section {
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.date-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
[data-theme="light"] .date-header {
|
||||
border-color: rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.date-header:hover {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
/* Section colors */
|
||||
[data-section="today"] .date-header {
|
||||
background: linear-gradient(135deg, rgba(59, 130, 246, 0.25), rgba(37, 99, 235, 0.15));
|
||||
border-left: 3px solid #3b82f6;
|
||||
}
|
||||
|
||||
[data-section="yesterday"] .date-header {
|
||||
background: linear-gradient(135deg, rgba(139, 92, 246, 0.25), rgba(124, 58, 237, 0.15));
|
||||
border-left: 3px solid #8b5cf6;
|
||||
}
|
||||
|
||||
[data-section="thisWeek"] .date-header {
|
||||
background: linear-gradient(135deg, rgba(20, 184, 166, 0.25), rgba(13, 148, 136, 0.15));
|
||||
border-left: 3px solid #14b8a6;
|
||||
}
|
||||
|
||||
[data-section="older"] .date-header {
|
||||
background: linear-gradient(135deg, rgba(249, 115, 22, 0.25), rgba(234, 88, 12, 0.15));
|
||||
border-left: 3px solid #f97316;
|
||||
}
|
||||
|
||||
.date-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.date-header-left svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--text-muted);
|
||||
transition: transform var(--transition-fast);
|
||||
}
|
||||
|
||||
.date-section.collapsed .date-header-left svg {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.date-label {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.date-sublabel {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
margin-left: var(--space-2);
|
||||
}
|
||||
|
||||
.date-section.collapsed .date-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.date-content {
|
||||
padding: var(--space-4) 0;
|
||||
}
|
||||
|
||||
/* Notes grid inside sections */
|
||||
.notes-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.note-card {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-4);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-base);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
[data-theme="light"] .note-card {
|
||||
border-color: rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.note-card:hover {
|
||||
border-color: var(--accent);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.note-title {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.note-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
/* Empty section */
|
||||
.empty-section {
|
||||
padding: var(--space-4);
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
/* Note viewer overlay */
|
||||
.note-viewer {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
z-index: 200;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.note-viewer.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.note-viewer-content {
|
||||
max-width: 800px;
|
||||
margin: var(--space-5) auto;
|
||||
background: var(--bg-base);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
min-height: calc(100vh - var(--space-10));
|
||||
}
|
||||
|
||||
.note-viewer-header {
|
||||
padding: var(--space-4) var(--space-5);
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--bg-base);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.note-viewer-header h2 {
|
||||
font-size: var(--text-lg);
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.note-viewer-body {
|
||||
padding: var(--space-6);
|
||||
}
|
||||
|
||||
/* Markdown */
|
||||
.markdown-body {
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.markdown-body h1 {
|
||||
font-size: 1.8rem;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--space-4);
|
||||
padding-bottom: var(--space-3);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.markdown-body h2 {
|
||||
font-size: 1.3rem;
|
||||
color: var(--accent);
|
||||
margin-top: var(--space-6);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.markdown-body h3 {
|
||||
font-size: 1.1rem;
|
||||
color: var(--text-primary);
|
||||
margin-top: var(--space-5);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.markdown-body p { margin-bottom: var(--space-4); }
|
||||
|
||||
.markdown-body ul, .markdown-body ol {
|
||||
margin-bottom: var(--space-4);
|
||||
padding-left: var(--space-6);
|
||||
}
|
||||
|
||||
.markdown-body li { margin-bottom: var(--space-2); }
|
||||
|
||||
.markdown-body a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.markdown-body a:hover { text-decoration: underline; }
|
||||
|
||||
.markdown-body code {
|
||||
background: var(--bg-surface);
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.markdown-body pre {
|
||||
background: var(--bg-surface);
|
||||
padding: var(--space-4);
|
||||
border-radius: var(--radius-md);
|
||||
overflow-x: auto;
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.markdown-body pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.markdown-body blockquote {
|
||||
border-left: 3px solid var(--accent);
|
||||
padding-left: var(--space-4);
|
||||
color: var(--text-muted);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.markdown-body strong { color: var(--text-primary); }
|
||||
|
||||
/* Tag filter pills */
|
||||
.tag-filter {
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
|
||||
.tag-filter-label {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
margin-bottom: var(--space-2);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.tag-pills {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.tag-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
[data-theme="light"] .tag-pill {
|
||||
border-color: rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.tag-pill:hover {
|
||||
background: var(--bg-surface-hover);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.tag-pill.active {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.tag-pill-count {
|
||||
font-size: var(--text-xs);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.clear-filters {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: var(--space-2);
|
||||
margin-left: var(--space-2);
|
||||
}
|
||||
|
||||
.clear-filters:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* No results */
|
||||
.no-results {
|
||||
text-align: center;
|
||||
padding: var(--space-10);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.no-results svg {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin-bottom: var(--space-4);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.search-bar { width: 100%; }
|
||||
|
||||
.notes-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.note-viewer-content {
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
min-height: 100vh;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="header">
|
||||
<a href="index.html" class="logo">
|
||||
<i data-lucide="circle-dot"></i>
|
||||
Echo
|
||||
</a>
|
||||
<nav class="nav">
|
||||
<a href="index.html" class="nav-item">
|
||||
<i data-lucide="layout-list"></i>
|
||||
<span>Tasks</span>
|
||||
</a>
|
||||
<a href="notes.html" class="nav-item active">
|
||||
<i data-lucide="file-text"></i>
|
||||
<span>Notes</span>
|
||||
</a>
|
||||
<a href="files.html" class="nav-item">
|
||||
<i data-lucide="folder"></i>
|
||||
<span>Files</span>
|
||||
</a>
|
||||
<button class="theme-toggle" onclick="toggleTheme()" title="Schimbă tema">
|
||||
<i data-lucide="sun" id="themeIcon"></i>
|
||||
</button>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="main">
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">Notes</h1>
|
||||
<div class="search-bar">
|
||||
<input type="text" class="input" id="searchInput" placeholder="Caută în notițe..." oninput="filterNotes()">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tag-filter">
|
||||
<span class="tag-filter-label">Filtrează după tags:</span>
|
||||
<div class="tag-pills" id="tagPills"></div>
|
||||
</div>
|
||||
|
||||
<div id="notesContainer">
|
||||
<div class="no-results">
|
||||
<i data-lucide="loader"></i>
|
||||
<p>Se încarcă...</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Note viewer overlay -->
|
||||
<div class="note-viewer" id="noteViewer" onclick="if(event.target === this) closeNote()">
|
||||
<div class="note-viewer-content">
|
||||
<div class="note-viewer-header">
|
||||
<h2 id="viewerTitle">Titlu</h2>
|
||||
<button class="btn btn-ghost" onclick="closeNote()">
|
||||
<i data-lucide="x"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="note-viewer-body">
|
||||
<div id="viewerContent" class="markdown-body"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Theme
|
||||
function initTheme() {
|
||||
const saved = localStorage.getItem('theme') || 'dark';
|
||||
document.documentElement.setAttribute('data-theme', saved);
|
||||
updateThemeIcon(saved);
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
const current = document.documentElement.getAttribute('data-theme') || 'dark';
|
||||
const next = current === 'dark' ? 'light' : 'dark';
|
||||
document.documentElement.setAttribute('data-theme', next);
|
||||
localStorage.setItem('theme', next);
|
||||
updateThemeIcon(next);
|
||||
}
|
||||
|
||||
function updateThemeIcon(theme) {
|
||||
const icon = document.getElementById('themeIcon');
|
||||
if (icon) {
|
||||
icon.setAttribute('data-lucide', theme === 'dark' ? 'sun' : 'moon');
|
||||
lucide.createIcons();
|
||||
}
|
||||
}
|
||||
|
||||
initTheme();
|
||||
lucide.createIcons();
|
||||
|
||||
const notesCache = {};
|
||||
|
||||
const notesIndex = [
|
||||
{
|
||||
"file": "2026-01-29_remotion-skill-claude-code.md",
|
||||
"title": "How people are generating videos with Claude Code",
|
||||
"date": "2026-01-29",
|
||||
"tags": ["claude", "remotion", "video", "automation"]
|
||||
},
|
||||
{
|
||||
"file": "2026-01-29_gsd-framework-claude-code.md",
|
||||
"title": "Forget Ralph Loops: The New GSD Framework for Claude",
|
||||
"date": "2026-01-29",
|
||||
"tags": ["claude-code", "gsd", "framework", "sub-agents"]
|
||||
},
|
||||
{
|
||||
"file": "2026-01-29_greseli-post-apa.md",
|
||||
"title": "Greșeli frecvente în timpul postului doar cu apă",
|
||||
"date": "2026-01-29",
|
||||
"tags": ["post", "water-fasting", "sănătate"]
|
||||
},
|
||||
{
|
||||
"file": "2026-01-29_clawdbot-security-vulnerabilities.md",
|
||||
"title": "It Got Worse (Clawdbot) - Security Vulnerabilities",
|
||||
"date": "2026-01-29",
|
||||
"tags": ["clawdbot", "security", "vulnerabilities"]
|
||||
},
|
||||
{
|
||||
"file": "2025-01-30_clawdbot-5-use-cases.md",
|
||||
"title": "5 Insane ClawdBot Use Cases You Need To Do Immediately",
|
||||
"date": "2025-01-30",
|
||||
"tags": ["clawdbot", "automation", "productivity"]
|
||||
},
|
||||
{
|
||||
"file": "2025-01-30_claude-code-do-work-pattern.md",
|
||||
"title": "The Most Powerful Claude Code Pattern I've Found",
|
||||
"date": "2025-01-30",
|
||||
"tags": ["claude-code", "skills", "workflow"]
|
||||
}
|
||||
];
|
||||
|
||||
const notesBasePath = "youtube-notes/";
|
||||
let selectedTags = new Set();
|
||||
|
||||
// Extract all tags with counts
|
||||
function getAllTags() {
|
||||
const tagCounts = {};
|
||||
notesIndex.forEach(note => {
|
||||
note.tags.forEach(tag => {
|
||||
tagCounts[tag] = (tagCounts[tag] || 0) + 1;
|
||||
});
|
||||
});
|
||||
// Sort by count descending
|
||||
return Object.entries(tagCounts)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([tag, count]) => ({ tag, count }));
|
||||
}
|
||||
|
||||
// Render tag pills
|
||||
function renderTagPills() {
|
||||
const container = document.getElementById('tagPills');
|
||||
const tags = getAllTags();
|
||||
|
||||
let html = tags.map(({ tag, count }) => `
|
||||
<span class="tag-pill ${selectedTags.has(tag) ? 'active' : ''}"
|
||||
onclick="toggleTag('${tag}')">
|
||||
${tag} <span class="tag-pill-count">(${count})</span>
|
||||
</span>
|
||||
`).join('');
|
||||
|
||||
if (selectedTags.size > 0) {
|
||||
html += `<button class="clear-filters" onclick="clearTagFilters()">✕ Clear</button>`;
|
||||
}
|
||||
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
// Toggle tag selection
|
||||
function toggleTag(tag) {
|
||||
if (selectedTags.has(tag)) {
|
||||
selectedTags.delete(tag);
|
||||
} else {
|
||||
selectedTags.add(tag);
|
||||
}
|
||||
renderTagPills();
|
||||
filterNotes();
|
||||
}
|
||||
|
||||
// Clear all tag filters
|
||||
function clearTagFilters() {
|
||||
selectedTags.clear();
|
||||
renderTagPills();
|
||||
filterNotes();
|
||||
}
|
||||
|
||||
// Filter notes by search and tags
|
||||
function filterNotes() {
|
||||
const query = document.getElementById('searchInput').value.toLowerCase().trim();
|
||||
|
||||
let filtered = notesIndex;
|
||||
|
||||
// Filter by selected tags (AND logic)
|
||||
if (selectedTags.size > 0) {
|
||||
filtered = filtered.filter(note =>
|
||||
[...selectedTags].every(tag => note.tags.includes(tag))
|
||||
);
|
||||
}
|
||||
|
||||
// Filter by search query
|
||||
if (query) {
|
||||
filtered = filtered.filter(note => {
|
||||
const titleMatch = note.title.toLowerCase().includes(query);
|
||||
const tagsMatch = note.tags.some(t => t.toLowerCase().includes(query));
|
||||
const contentMatch = (notesCache[note.file] || '').toLowerCase().includes(query);
|
||||
return titleMatch || tagsMatch || contentMatch;
|
||||
});
|
||||
}
|
||||
|
||||
renderNotesAccordion(filtered);
|
||||
}
|
||||
|
||||
// Group notes by date category
|
||||
function groupNotesByDate(notes) {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
|
||||
const weekAgo = new Date(today);
|
||||
weekAgo.setDate(weekAgo.getDate() - 7);
|
||||
|
||||
const groups = {
|
||||
today: { label: 'Azi', sublabel: formatDate(today), notes: [], expanded: true },
|
||||
yesterday: { label: 'Ieri', sublabel: formatDate(yesterday), notes: [], expanded: false },
|
||||
thisWeek: { label: 'Săptămâna aceasta', sublabel: '', notes: [], expanded: false },
|
||||
older: { label: 'Mai vechi', sublabel: '', notes: [], expanded: false }
|
||||
};
|
||||
|
||||
notes.forEach(note => {
|
||||
const noteDate = new Date(note.date);
|
||||
noteDate.setHours(0, 0, 0, 0);
|
||||
|
||||
if (noteDate.getTime() === today.getTime()) {
|
||||
groups.today.notes.push(note);
|
||||
} else if (noteDate.getTime() === yesterday.getTime()) {
|
||||
groups.yesterday.notes.push(note);
|
||||
} else if (noteDate >= weekAgo) {
|
||||
groups.thisWeek.notes.push(note);
|
||||
} else {
|
||||
groups.older.notes.push(note);
|
||||
}
|
||||
});
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
function formatDate(date) {
|
||||
return date.toLocaleDateString('ro-RO', { day: 'numeric', month: 'short', year: 'numeric' });
|
||||
}
|
||||
|
||||
function renderNotesAccordion(notes = notesIndex) {
|
||||
const container = document.getElementById('notesContainer');
|
||||
|
||||
if (notes.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="no-results">
|
||||
<i data-lucide="search-x"></i>
|
||||
<p>Nicio notiță găsită</p>
|
||||
</div>
|
||||
`;
|
||||
lucide.createIcons();
|
||||
return;
|
||||
}
|
||||
|
||||
const groups = groupNotesByDate(notes);
|
||||
let html = '';
|
||||
|
||||
Object.entries(groups).forEach(([key, group]) => {
|
||||
if (group.notes.length === 0) return;
|
||||
|
||||
const collapsed = group.expanded ? '' : 'collapsed';
|
||||
|
||||
html += `
|
||||
<div class="date-section ${collapsed}" data-section="${key}">
|
||||
<div class="date-header" onclick="toggleSection('${key}')">
|
||||
<div class="date-header-left">
|
||||
<i data-lucide="chevron-down"></i>
|
||||
<span class="date-label">${group.label}</span>
|
||||
<span class="date-sublabel">${group.sublabel}</span>
|
||||
</div>
|
||||
<span class="badge">${group.notes.length}</span>
|
||||
</div>
|
||||
<div class="date-content">
|
||||
<div class="notes-grid">
|
||||
${group.notes.map(note => renderNoteCard(note)).join('')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
container.innerHTML = html;
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
||||
function renderNoteCard(note) {
|
||||
return `
|
||||
<div class="note-card" onclick="openNote('${note.file}')">
|
||||
<div class="note-title">${note.title}</div>
|
||||
<div class="note-tags">
|
||||
${note.tags.map(t => `<span class="tag">${t}</span>`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function toggleSection(sectionKey) {
|
||||
const section = document.querySelector(`[data-section="${sectionKey}"]`);
|
||||
section.classList.toggle('collapsed');
|
||||
}
|
||||
|
||||
async function openNote(file) {
|
||||
const note = notesIndex.find(n => n.file === file);
|
||||
if (!note) return;
|
||||
|
||||
document.getElementById('viewerTitle').textContent = note.title;
|
||||
document.getElementById('viewerContent').innerHTML = '<p style="color: var(--text-muted)">Se încarcă...</p>';
|
||||
document.getElementById('noteViewer').classList.add('active');
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
// Update URL
|
||||
const noteId = file.replace(/^\d{4}-\d{2}-\d{2}_/, '').replace(/\.md$/, '');
|
||||
history.replaceState(null, '', '#' + noteId);
|
||||
|
||||
try {
|
||||
let content = notesCache[file];
|
||||
if (!content) {
|
||||
const response = await fetch(notesBasePath + file);
|
||||
content = await response.text();
|
||||
notesCache[file] = content;
|
||||
}
|
||||
document.getElementById('viewerContent').innerHTML = marked.parse(content);
|
||||
} catch (error) {
|
||||
document.getElementById('viewerContent').innerHTML = `<p style="color: var(--error)">Eroare: ${error.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function closeNote() {
|
||||
document.getElementById('noteViewer').classList.remove('active');
|
||||
document.body.style.overflow = '';
|
||||
history.replaceState(null, '', window.location.pathname);
|
||||
}
|
||||
|
||||
async function preloadNotes() {
|
||||
for (const note of notesIndex) {
|
||||
try {
|
||||
const response = await fetch(notesBasePath + note.file);
|
||||
notesCache[note.file] = await response.text();
|
||||
} catch (e) {
|
||||
notesCache[note.file] = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// searchNotes replaced by filterNotes above
|
||||
|
||||
// Handle hash for deep links
|
||||
function checkHash() {
|
||||
if (window.location.hash) {
|
||||
const id = window.location.hash.slice(1);
|
||||
const note = notesIndex.find(n => {
|
||||
const noteId = n.file.replace(/^\d{4}-\d{2}-\d{2}_/, '').replace(/\.md$/, '');
|
||||
return noteId === id;
|
||||
});
|
||||
if (note) {
|
||||
openNote(note.file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ESC to close
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.key === 'Escape') closeNote();
|
||||
});
|
||||
|
||||
// Init
|
||||
renderTagPills();
|
||||
renderNotesAccordion();
|
||||
preloadNotes();
|
||||
checkHash();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
123
kanban/swipe-nav.js
Normal file
123
kanban/swipe-nav.js
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Swipe Navigation for Echo
|
||||
* Swipe left/right to navigate between pages
|
||||
*/
|
||||
(function() {
|
||||
const pages = ['index.html', 'notes.html', 'files.html'];
|
||||
|
||||
// Get current page index
|
||||
function getCurrentIndex() {
|
||||
const path = window.location.pathname;
|
||||
let filename = path.split('/').pop() || 'index.html';
|
||||
// Handle /echo/ without filename
|
||||
if (filename === '' || filename === 'echo') filename = 'index.html';
|
||||
const idx = pages.indexOf(filename);
|
||||
return idx >= 0 ? idx : 0;
|
||||
}
|
||||
|
||||
// Navigate to page
|
||||
function navigateTo(index) {
|
||||
if (index >= 0 && index < pages.length) {
|
||||
window.location.href = pages[index];
|
||||
}
|
||||
}
|
||||
|
||||
// Swipe detection
|
||||
let touchStartX = 0;
|
||||
let touchStartY = 0;
|
||||
let touchEndX = 0;
|
||||
let touchEndY = 0;
|
||||
|
||||
const minSwipeDistance = 80;
|
||||
const maxVerticalDistance = 100;
|
||||
|
||||
document.addEventListener('touchstart', function(e) {
|
||||
touchStartX = e.changedTouches[0].screenX;
|
||||
touchStartY = e.changedTouches[0].screenY;
|
||||
}, { passive: true });
|
||||
|
||||
document.addEventListener('touchend', function(e) {
|
||||
touchEndX = e.changedTouches[0].screenX;
|
||||
touchEndY = e.changedTouches[0].screenY;
|
||||
handleSwipe();
|
||||
}, { passive: true });
|
||||
|
||||
function handleSwipe() {
|
||||
const deltaX = touchEndX - touchStartX;
|
||||
const deltaY = Math.abs(touchEndY - touchStartY);
|
||||
|
||||
// Ignore if vertical swipe or too short
|
||||
if (deltaY > maxVerticalDistance) return;
|
||||
if (Math.abs(deltaX) < minSwipeDistance) return;
|
||||
|
||||
const currentIndex = getCurrentIndex();
|
||||
|
||||
if (deltaX > 0) {
|
||||
// Swipe right → previous page
|
||||
navigateTo(currentIndex - 1);
|
||||
} else {
|
||||
// Swipe left → next page
|
||||
navigateTo(currentIndex + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Visual indicator (optional dots)
|
||||
function createIndicator() {
|
||||
const indicator = document.createElement('div');
|
||||
indicator.className = 'swipe-indicator';
|
||||
indicator.innerHTML = pages.map((_, i) =>
|
||||
`<span class="swipe-dot ${i === getCurrentIndex() ? 'active' : ''}"></span>`
|
||||
).join('');
|
||||
document.body.appendChild(indicator);
|
||||
}
|
||||
|
||||
// Add indicator styles
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
.swipe-indicator {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
z-index: 9999;
|
||||
padding: 10px 16px;
|
||||
background: rgba(50, 50, 60, 0.9);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 24px;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
.swipe-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.swipe-dot.active {
|
||||
background: #3b82f6;
|
||||
border-color: #3b82f6;
|
||||
transform: scale(1.3);
|
||||
box-shadow: 0 0 8px rgba(59, 130, 246, 0.6);
|
||||
}
|
||||
@media (min-width: 769px) {
|
||||
.swipe-indicator { display: none; }
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
|
||||
// Init after DOM ready
|
||||
function init() {
|
||||
if ('ontouchstart' in window || navigator.maxTouchPoints > 0) {
|
||||
createIndicator();
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
189
kanban/tasks.json
Normal file
189
kanban/tasks.json
Normal file
@@ -0,0 +1,189 @@
|
||||
{
|
||||
"lastUpdated": "2026-01-29T11:53:32.969Z",
|
||||
"columns": [
|
||||
{
|
||||
"id": "backlog",
|
||||
"name": "Backlog",
|
||||
"tasks": [
|
||||
{
|
||||
"id": "task-006",
|
||||
"title": "Email digest dimineața",
|
||||
"description": "Sumar email-uri importante la 8 AM",
|
||||
"created": "2025-01-30",
|
||||
"priority": "medium"
|
||||
},
|
||||
{
|
||||
"id": "task-007",
|
||||
"title": "Calendar sync",
|
||||
"description": "Alertă înainte de întâlniri Google Calendar",
|
||||
"created": "2025-01-30",
|
||||
"priority": "low"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "in-progress",
|
||||
"name": "In Progress",
|
||||
"tasks": []
|
||||
},
|
||||
{
|
||||
"id": "done",
|
||||
"name": "Done",
|
||||
"tasks": [
|
||||
{
|
||||
"id": "task-027",
|
||||
"title": "UI fixes: kanban icons + notes tags",
|
||||
"description": "Scos emoji din coloane kanban. Adăugat tag pills cu multi-select și count în notes.",
|
||||
"created": "2026-01-29",
|
||||
"priority": "high",
|
||||
"completed": "2026-01-29"
|
||||
},
|
||||
{
|
||||
"id": "task-026",
|
||||
"title": "Swipe navigation mobil",
|
||||
"description": "Swipe stânga/dreapta pentru navigare între Tasks ↔ Notes ↔ Files. Indicator dots pe mobil.",
|
||||
"created": "2026-01-29",
|
||||
"priority": "high",
|
||||
"completed": "2026-01-29"
|
||||
},
|
||||
{
|
||||
"id": "task-025",
|
||||
"title": "Notes: Accordion pe zile",
|
||||
"description": "Grupare: Azi (expanded), Ieri, Săptămâna aceasta, Mai vechi (collapsed). Click pentru expand/collapse.",
|
||||
"created": "2026-01-29",
|
||||
"priority": "high",
|
||||
"completed": "2026-01-29"
|
||||
},
|
||||
{
|
||||
"id": "task-024",
|
||||
"title": "Fix contrast dark/light mode",
|
||||
"description": "Text și borders mai vizibile, header alb în light mode, toggle temă funcțional",
|
||||
"created": "2026-01-29",
|
||||
"priority": "high",
|
||||
"completed": "2026-01-29"
|
||||
},
|
||||
{
|
||||
"id": "task-023",
|
||||
"title": "Design System Unificat",
|
||||
"description": "common.css + Lucide icons + UI modern pe toate paginile: Tasks, Notes, Files",
|
||||
"created": "2026-01-29",
|
||||
"priority": "high",
|
||||
"completed": "2026-01-29"
|
||||
},
|
||||
{
|
||||
"id": "task-022",
|
||||
"title": "Unificare stil navigare",
|
||||
"description": "Nav unificat pe toate paginile: 📋 Tasks | 📝 Notes | 📁 Files cu iconuri și stil consistent",
|
||||
"created": "2026-01-29",
|
||||
"priority": "high",
|
||||
"completed": "2026-01-29"
|
||||
},
|
||||
{
|
||||
"id": "task-021",
|
||||
"title": "UI/UX Redesign v2",
|
||||
"description": "Kanban: doar In Progress expandat. Notes: mobile tabs. Files: Browse/Editor tabs cu grid.",
|
||||
"created": "2026-01-29",
|
||||
"priority": "high",
|
||||
"completed": "2026-01-29"
|
||||
},
|
||||
{
|
||||
"id": "task-020",
|
||||
"title": "UI Responsive & Compact",
|
||||
"description": "Coloane colapsabile, task-uri compacte (click expand), sidebar toggle, Done minimizat by default",
|
||||
"created": "2026-01-29",
|
||||
"priority": "high",
|
||||
"completed": "2026-01-29"
|
||||
},
|
||||
{
|
||||
"id": "task-019",
|
||||
"title": "Comparare bilanț 12/2025 vs 12/2024",
|
||||
"description": "Doar S1002 modificat! Câmpuri noi: AN_CAEN, d_audit_intern. Raport: bilant_compare/2025_vs_2024/",
|
||||
"created": "2026-01-29",
|
||||
"priority": "high",
|
||||
"completed": "2026-01-29"
|
||||
},
|
||||
{
|
||||
"id": "task-018",
|
||||
"title": "Comparare bilanț ANAF 2024 vs 2023",
|
||||
"description": "Comparat XSD-uri S1002-S1005. Raport: anaf-monitor/bilant_compare/RAPORT_DIFERENTE_2024_vs_2023.md",
|
||||
"created": "2026-01-29",
|
||||
"priority": "high",
|
||||
"completed": "2026-01-29"
|
||||
},
|
||||
{
|
||||
"id": "task-017",
|
||||
"title": "Scrie un haiku",
|
||||
"description": "Biți în noaptea grea / Claude răspunde în liniște / Ecou digital",
|
||||
"created": "2026-01-29",
|
||||
"priority": "medium",
|
||||
"completed": "2026-01-29"
|
||||
},
|
||||
{
|
||||
"id": "task-005",
|
||||
"title": "Kanban board",
|
||||
"description": "Interfață web pentru vizualizare task-uri",
|
||||
"created": "2025-01-30",
|
||||
"priority": "high",
|
||||
"completed": "2026-01-29"
|
||||
},
|
||||
{
|
||||
"id": "task-008",
|
||||
"title": "YouTube Notes interface",
|
||||
"description": "Interfață pentru vizualizare notițe cu search",
|
||||
"created": "2026-01-29",
|
||||
"priority": "high"
|
||||
},
|
||||
{
|
||||
"id": "task-009",
|
||||
"title": "Search în notițe",
|
||||
"description": "Căutare în titlu, tags și conținut",
|
||||
"created": "2026-01-29",
|
||||
"priority": "medium"
|
||||
},
|
||||
{
|
||||
"id": "task-010",
|
||||
"title": "Sumarizare: Claude Code Do Work Pattern",
|
||||
"description": "https://youtu.be/I9-tdhxiH7w",
|
||||
"created": "2026-01-29",
|
||||
"priority": "high"
|
||||
},
|
||||
{
|
||||
"id": "task-011",
|
||||
"title": "File Explorer în Task Board",
|
||||
"description": "Interfață pentru browse/edit fișiere din workspace",
|
||||
"created": "2026-01-29",
|
||||
"priority": "high"
|
||||
},
|
||||
{
|
||||
"id": "task-013",
|
||||
"title": "Kanban interactiv cu drag & drop",
|
||||
"description": "Adăugat: drag-drop, add/edit/delete tasks, priorități, salvare automată",
|
||||
"created": "2026-01-29",
|
||||
"priority": "high"
|
||||
},
|
||||
{
|
||||
"id": "task-014",
|
||||
"title": "Sumarizare: It Got Worse (Clawdbot)...",
|
||||
"description": "https://youtu.be/rPAKq2oQVBs?si=6sJk41XsCrQQt6Lg",
|
||||
"created": "2026-01-29",
|
||||
"priority": "medium",
|
||||
"completed": "2026-01-29"
|
||||
},
|
||||
{
|
||||
"id": "task-015",
|
||||
"title": "Sumarizare: Greșeli post cu apă",
|
||||
"description": "https://youtu.be/4QjkI0sf64M",
|
||||
"created": "2026-01-29",
|
||||
"priority": "medium"
|
||||
},
|
||||
{
|
||||
"id": "task-016",
|
||||
"title": "Sumarizare: GSD Framework Claude Code",
|
||||
"description": "https://www.youtube.com/watch?v=l94A53kIUB0",
|
||||
"created": "2026-01-29",
|
||||
"priority": "high"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
120
kanban/update_task.py
Normal file
120
kanban/update_task.py
Normal file
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Helper script for Echo to update kanban tasks.
|
||||
Usage: python3 update_task.py <action> <args>
|
||||
|
||||
Actions:
|
||||
add <column> <title> [description] [priority]
|
||||
move <task_id> <to_column>
|
||||
done <task_id>
|
||||
list
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
TASKS_FILE = Path(__file__).parent / 'tasks.json'
|
||||
|
||||
def load_tasks():
|
||||
with open(TASKS_FILE, 'r') as f:
|
||||
return json.load(f)
|
||||
|
||||
def save_tasks(data):
|
||||
data['lastUpdated'] = datetime.utcnow().isoformat() + 'Z'
|
||||
with open(TASKS_FILE, 'w') as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
|
||||
def get_next_id(data):
|
||||
max_id = 0
|
||||
for col in data['columns']:
|
||||
for task in col['tasks']:
|
||||
num = int(task['id'].split('-')[1])
|
||||
if num > max_id:
|
||||
max_id = num
|
||||
return f"task-{max_id + 1:03d}"
|
||||
|
||||
def add_task(column_id, title, description="", priority="medium"):
|
||||
data = load_tasks()
|
||||
new_task = {
|
||||
"id": get_next_id(data),
|
||||
"title": title,
|
||||
"description": description,
|
||||
"created": datetime.now().strftime("%Y-%m-%d"),
|
||||
"priority": priority
|
||||
}
|
||||
|
||||
for col in data['columns']:
|
||||
if col['id'] == column_id:
|
||||
col['tasks'].append(new_task)
|
||||
save_tasks(data)
|
||||
print(f"Added: {new_task['id']} - {title}")
|
||||
return
|
||||
|
||||
print(f"Column not found: {column_id}")
|
||||
|
||||
def move_task(task_id, to_column):
|
||||
data = load_tasks()
|
||||
task = None
|
||||
|
||||
# Find and remove task
|
||||
for col in data['columns']:
|
||||
for t in col['tasks']:
|
||||
if t['id'] == task_id:
|
||||
task = t
|
||||
col['tasks'].remove(t)
|
||||
break
|
||||
if task:
|
||||
break
|
||||
|
||||
if not task:
|
||||
print(f"Task not found: {task_id}")
|
||||
return
|
||||
|
||||
# Add to new column
|
||||
if to_column == 'done':
|
||||
task['completed'] = datetime.now().strftime("%Y-%m-%d")
|
||||
|
||||
for col in data['columns']:
|
||||
if col['id'] == to_column:
|
||||
col['tasks'].append(task)
|
||||
save_tasks(data)
|
||||
print(f"Moved: {task_id} -> {to_column}")
|
||||
return
|
||||
|
||||
print(f"Column not found: {to_column}")
|
||||
|
||||
def done_task(task_id):
|
||||
move_task(task_id, 'done')
|
||||
|
||||
def list_tasks():
|
||||
data = load_tasks()
|
||||
for col in data['columns']:
|
||||
print(f"\n{col['name']} ({len(col['tasks'])})")
|
||||
print("-" * 40)
|
||||
for task in col['tasks']:
|
||||
print(f" [{task['id']}] {task['title']}")
|
||||
|
||||
if __name__ == '__main__':
|
||||
if len(sys.argv) < 2:
|
||||
print(__doc__)
|
||||
sys.exit(1)
|
||||
|
||||
action = sys.argv[1]
|
||||
|
||||
if action == 'add' and len(sys.argv) >= 4:
|
||||
add_task(
|
||||
sys.argv[2], # column
|
||||
sys.argv[3], # title
|
||||
sys.argv[4] if len(sys.argv) > 4 else "",
|
||||
sys.argv[5] if len(sys.argv) > 5 else "medium"
|
||||
)
|
||||
elif action == 'move' and len(sys.argv) >= 4:
|
||||
move_task(sys.argv[2], sys.argv[3])
|
||||
elif action == 'done' and len(sys.argv) >= 3:
|
||||
done_task(sys.argv[2])
|
||||
elif action == 'list':
|
||||
list_tasks()
|
||||
else:
|
||||
print(__doc__)
|
||||
1
kanban/youtube-notes
Symbolic link
1
kanban/youtube-notes
Symbolic link
@@ -0,0 +1 @@
|
||||
../notes/youtube
|
||||
Reference in New Issue
Block a user