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:
Echo
2026-01-29 13:11:59 +00:00
commit f9912e0081
52 changed files with 23148 additions and 0 deletions

306
kanban/api.py Normal file
View 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()

View 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
View 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
View 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); }

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1 @@
../notes/youtube