Restructurare completă: kanban→dashboard, notes→kb, ANAF→tools/

- Mutare și reorganizare foldere proiecte
- Actualizare path-uri în TOOLS.md
- Sincronizare configurații agenți
- 79 fișiere actualizate
This commit is contained in:
Echo
2026-01-31 09:34:24 +00:00
parent 838c38e82f
commit a44b9ef852
99 changed files with 2096 additions and 623 deletions

450
dashboard/api.py Normal file
View File

@@ -0,0 +1,450 @@
#!/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 / 'kb' / 'youtube'
KANBAN_DIR = BASE_DIR / 'dashboard'
class TaskBoardHandler(SimpleHTTPRequestHandler):
def do_POST(self):
if self.path == '/api/youtube':
self.handle_youtube()
elif self.path == '/api/files':
self.handle_files_post()
elif self.path == '/api/refresh-index':
self.handle_refresh_index()
else:
self.send_error(404)
def handle_refresh_index(self):
"""Regenerate kb/index.json"""
try:
script = TOOLS_DIR / 'update_notes_index.py'
result = subprocess.run(
[sys.executable, str(script)],
capture_output=True,
text=True,
timeout=30
)
if result.returncode == 0:
# Parse output for stats
output = result.stdout
total_match = re.search(r'with (\d+) notes', output)
total = int(total_match.group(1)) if total_match else 0
self.send_json({
'success': True,
'message': f'Index regenerat cu {total} notițe',
'total': total,
'output': output
})
else:
self.send_json({
'success': False,
'error': result.stderr or 'Unknown error'
}, 500)
except subprocess.TimeoutExpired:
self.send_json({'success': False, 'error': 'Timeout'}, 500)
except Exception as e:
self.send_json({'success': False, 'error': str(e)}, 500)
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 == '/api/git' or self.path.startswith('/api/git?'):
self.handle_git_status()
elif self.path == '/api/agents' or self.path.startswith('/api/agents?'):
self.handle_agents_status()
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_git_status(self):
"""Get git status for dashboard."""
try:
workspace = Path('/home/moltbot/clawd')
# Get current branch
branch = subprocess.run(
['git', 'branch', '--show-current'],
cwd=workspace, capture_output=True, text=True, timeout=5
).stdout.strip()
# Get last commit
last_commit = subprocess.run(
['git', 'log', '-1', '--format=%h|%s|%cr'],
cwd=workspace, capture_output=True, text=True, timeout=5
).stdout.strip()
commit_parts = last_commit.split('|') if last_commit else ['', '', '']
# Get uncommitted files
status_output = subprocess.run(
['git', 'status', '--short'],
cwd=workspace, capture_output=True, text=True, timeout=5
).stdout.strip()
uncommitted = status_output.split('\n') if status_output else []
uncommitted = [f for f in uncommitted if f.strip()]
# Get diff stats if there are uncommitted files
diff_stat = ''
if uncommitted:
diff_stat = subprocess.run(
['git', 'diff', '--stat', '--cached'],
cwd=workspace, capture_output=True, text=True, timeout=5
).stdout.strip()
if not diff_stat:
diff_stat = subprocess.run(
['git', 'diff', '--stat'],
cwd=workspace, capture_output=True, text=True, timeout=5
).stdout.strip()
self.send_json({
'branch': branch,
'lastCommit': {
'hash': commit_parts[0] if len(commit_parts) > 0 else '',
'message': commit_parts[1] if len(commit_parts) > 1 else '',
'time': commit_parts[2] if len(commit_parts) > 2 else ''
},
'uncommitted': uncommitted,
'uncommittedCount': len(uncommitted),
'diffStat': diff_stat,
'clean': len(uncommitted) == 0
})
except Exception as e:
self.send_json({'error': str(e)}, 500)
def handle_agents_status(self):
"""Get agents status - fast version reading session files directly."""
try:
# Define known agents
agents_config = [
{'id': 'echo', 'name': 'Echo', 'emoji': '🌀'},
{'id': 'echo-work', 'name': 'Work', 'emoji': ''},
{'id': 'echo-health', 'name': 'Health', 'emoji': '❤️'},
{'id': 'echo-growth', 'name': 'Growth', 'emoji': '🪜'},
{'id': 'echo-sprijin', 'name': 'Sprijin', 'emoji': ''},
{'id': 'echo-scout', 'name': 'Scout', 'emoji': '⚜️'},
]
# Check active sessions by reading session files directly (fast)
active_agents = set()
sessions_base = Path.home() / '.clawdbot' / 'agents'
if sessions_base.exists():
for agent_dir in sessions_base.iterdir():
if agent_dir.is_dir():
sessions_file = agent_dir / 'sessions' / 'sessions.json'
if sessions_file.exists():
try:
data = json.loads(sessions_file.read_text())
# sessions.json is an object with session keys
now = datetime.now().timestamp() * 1000
for key, sess in data.items():
if isinstance(sess, dict):
last_active = sess.get('updatedAt', 0)
if now - last_active < 30 * 60 * 1000: # 30 min
active_agents.add(agent_dir.name)
break
except:
pass
# Build response
agents = []
for cfg in agents_config:
agents.append({
'id': cfg['id'],
'name': cfg['name'],
'emoji': cfg['emoji'],
'active': cfg['id'] in active_agents
})
self.send_json({'agents': agents})
except Exception as e:
self.send_json({'error': str(e)}, 500)
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"
}
]
}

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
dashboard/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); }

1
dashboard/conversations Symbolic link
View File

@@ -0,0 +1 @@
../conversations

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

707
dashboard/files.html Normal file
View File

@@ -0,0 +1,707 @@
<!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="https://cdn.jsdelivr.net/npm/marked/marked.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;
}
#markdownPreview {
display: none;
width: 100%;
height: 100%;
padding: var(--space-5);
overflow-y: auto;
color: var(--text-secondary);
line-height: 1.7;
}
#markdownPreview h1, #markdownPreview h2, #markdownPreview h3 {
color: var(--text-primary);
margin-top: 1.5em;
margin-bottom: 0.5em;
}
#markdownPreview h1 { font-size: 1.8em; border-bottom: 1px solid var(--border); padding-bottom: 0.3em; }
#markdownPreview h2 { font-size: 1.4em; }
#markdownPreview h3 { font-size: 1.2em; }
#markdownPreview p { margin-bottom: 1em; }
#markdownPreview ul, #markdownPreview ol { margin-bottom: 1em; padding-left: 2em; }
#markdownPreview li { margin-bottom: 0.3em; }
#markdownPreview strong { color: var(--text-primary); }
#markdownPreview hr { border: none; border-top: 1px solid var(--border); margin: 2em 0; }
#markdownPreview code { background: var(--bg-surface); padding: 2px 6px; border-radius: 4px; font-family: var(--font-mono); }
#markdownPreview pre { background: var(--bg-surface); padding: 1em; border-radius: 8px; overflow-x: auto; }
#markdownPreview blockquote { border-left: 3px solid var(--accent); padding-left: 1em; margin-left: 0; color: var(--text-muted); }
.preview-active #codeEditor { display: none; }
.preview-active #markdownPreview { display: block; }
.btn-preview.active { background: var(--accent); color: white; }
.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>KB</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 btn-preview" onclick="togglePreview()" id="previewBtn" style="display:none;" title="Preview Markdown">
<i data-lucide="eye"></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" id="editorBody">
<textarea id="codeEditor" placeholder="Selectează un fișier..."></textarea>
<div id="markdownPreview"></div>
</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);
// Show preview button for markdown files
const isMarkdown = path.endsWith('.md');
document.getElementById('previewBtn').style.display = isMarkdown ? 'flex' : 'none';
// Auto-activate preview for markdown files
if (isMarkdown) {
const preview = document.getElementById('markdownPreview');
preview.innerHTML = marked.parse(data.content);
document.getElementById('editorBody').classList.add('preview-active');
document.getElementById('previewBtn').classList.add('active');
} else {
document.getElementById('editorBody').classList.remove('preview-active');
document.getElementById('previewBtn').classList.remove('active');
}
if (data.truncated) {
setStatus('Fișier trunchiat', 'error');
} else {
setStatus('Loaded', 'saved');
}
isModified = false;
showEditor();
}
function togglePreview() {
const editorBody = document.getElementById('editorBody');
const previewBtn = document.getElementById('previewBtn');
const preview = document.getElementById('markdownPreview');
const content = document.getElementById('codeEditor').value;
if (editorBody.classList.contains('preview-active')) {
// Switch to edit mode
editorBody.classList.remove('preview-active');
previewBtn.classList.remove('active');
setStatus('Edit mode', 'saved');
} else {
// Switch to preview mode
preview.innerHTML = marked.parse(content);
editorBody.classList.add('preview-active');
previewBtn.classList.add('active');
setStatus('Preview mode', 'saved');
}
}
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>

497
dashboard/grup-sprijin.html Normal file
View File

@@ -0,0 +1,497 @@
<!DOCTYPE html>
<html lang="ro">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Echo · Grup Sprijin</title>
<link rel="stylesheet" href="common.css">
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
<style>
.main {
max-width: 1000px;
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 input {
width: 250px;
}
.filters {
display: flex;
gap: var(--space-2);
flex-wrap: wrap;
margin-bottom: var(--space-4);
}
.filter-btn {
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-md);
border: 1px solid var(--border);
background: var(--bg-surface);
color: var(--text-secondary);
cursor: pointer;
font-size: var(--text-sm);
transition: all var(--transition-fast);
}
.filter-btn:hover {
background: var(--bg-surface-hover);
}
.filter-btn.active {
background: var(--accent);
color: white;
border-color: var(--accent);
}
.items-grid {
display: grid;
gap: var(--space-4);
}
.item-card {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: var(--space-4);
cursor: pointer;
transition: all var(--transition-fast);
}
.item-card:hover {
border-color: var(--accent);
transform: translateY(-2px);
}
.item-card.used {
opacity: 0.7;
border-left: 3px solid var(--success);
}
.item-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: var(--space-2);
}
.item-title {
font-weight: 600;
color: var(--text-primary);
}
.item-type {
font-size: var(--text-xs);
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-sm);
text-transform: uppercase;
}
.item-type.exercitiu { background: rgba(59, 130, 246, 0.2); color: #3b82f6; }
.item-type.meditatie { background: rgba(139, 92, 246, 0.2); color: #8b5cf6; }
.item-type.intrebare { background: rgba(20, 184, 166, 0.2); color: #14b8a6; }
.item-type.reflectie { background: rgba(249, 115, 22, 0.2); color: #f97316; }
.item-tags {
display: flex;
gap: var(--space-1);
flex-wrap: wrap;
margin-top: var(--space-2);
}
.tag {
font-size: var(--text-xs);
padding: 2px 6px;
background: var(--bg-surface-hover);
border-radius: var(--radius-sm);
color: var(--text-muted);
}
.item-used {
font-size: var(--text-xs);
color: var(--success);
margin-top: var(--space-2);
}
/* Modal */
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.7);
z-index: 1000;
align-items: center;
justify-content: center;
}
.modal.open {
display: flex;
}
.modal-content {
background: #1a1a2e;
border-radius: var(--radius-lg);
max-width: 600px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
padding: var(--space-5);
box-shadow: 0 20px 50px rgba(0,0,0,0.5);
}
[data-theme="light"] .modal-content {
background: #ffffff;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: var(--space-4);
}
.modal-title {
font-size: var(--text-lg);
font-weight: 600;
}
.modal-close {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: var(--space-1);
}
.modal-body {
color: var(--text-secondary);
line-height: 1.6;
white-space: pre-wrap;
}
.modal-actions {
margin-top: var(--space-4);
display: flex;
gap: var(--space-2);
}
.btn {
padding: var(--space-2) var(--space-4);
border-radius: var(--radius-md);
border: none;
cursor: pointer;
font-size: var(--text-sm);
}
.btn-primary {
background: var(--accent);
color: white;
}
.btn-secondary {
background: var(--bg-surface);
color: var(--text-secondary);
border: 1px solid var(--border);
}
.stats {
display: flex;
gap: var(--space-4);
margin-bottom: var(--space-4);
font-size: var(--text-sm);
color: var(--text-muted);
}
.error-msg {
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
color: #ef4444;
padding: var(--space-4);
border-radius: var(--radius-md);
text-align: center;
}
</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">
<i data-lucide="folder"></i>
<span>Files</span>
</a>
<a href="grup-sprijin.html" class="nav-item active">
<i data-lucide="heart-handshake"></i>
<span>Grup</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">Grup Sprijin - Exerciții & Întrebări</h1>
<div class="search-bar">
<input type="text" class="input" placeholder="Caută..." id="searchInput">
</div>
</div>
<div class="stats" id="stats"></div>
<div class="fise-section" id="fiseSection" style="margin-bottom: var(--space-5); display: none;">
<h2 style="font-size: var(--text-lg); margin-bottom: var(--space-3); color: var(--text-primary);">Fișe întâlniri</h2>
<div class="fise-list" id="fiseList" style="display: flex; gap: var(--space-2); flex-wrap: wrap;"></div>
</div>
<div class="filters" id="filters">
<button class="filter-btn active" data-filter="all">Toate</button>
<button class="filter-btn" data-filter="exercitiu">Exerciții</button>
<button class="filter-btn" data-filter="meditatie">Meditații</button>
<button class="filter-btn" data-filter="intrebare">Întrebări</button>
<button class="filter-btn" data-filter="reflectie">Reflecții</button>
<button class="filter-btn" data-filter="unused">Nefolosite</button>
<button class="filter-btn" data-filter="used">Folosite</button>
</div>
<div class="items-grid" id="itemsGrid">
<p>Se încarcă...</p>
</div>
</main>
<div class="modal" id="modal">
<div class="modal-content">
<div class="modal-header">
<div>
<h2 class="modal-title" id="modalTitle"></h2>
<span class="item-type" id="modalType"></span>
</div>
<button class="modal-close" onclick="closeModal()">
<i data-lucide="x"></i>
</button>
</div>
<div class="modal-body" id="modalBody"></div>
<div class="item-tags" id="modalTags"></div>
<div class="modal-actions">
<button class="btn btn-primary" id="markUsedBtn" onclick="toggleUsed()">Marchează folosit</button>
</div>
</div>
</div>
<script>
// Theme
function toggleTheme() {
const body = document.body;
const current = body.getAttribute('data-theme') || 'dark';
const next = current === 'dark' ? 'light' : 'dark';
body.setAttribute('data-theme', next);
localStorage.setItem('theme', next);
updateThemeIcon();
}
function updateThemeIcon() {
const theme = document.body.getAttribute('data-theme') || 'dark';
const icon = document.getElementById('themeIcon');
if (icon) {
icon.setAttribute('data-lucide', theme === 'dark' ? 'sun' : 'moon');
lucide.createIcons();
}
}
// Load saved theme
const savedTheme = localStorage.getItem('theme') || 'dark';
document.body.setAttribute('data-theme', savedTheme);
// Data
const API_BASE = window.location.pathname.includes('/echo/') ? '/echo' : '';
let items = [];
let currentFilter = 'all';
let currentItem = null;
async function loadItems() {
try {
const response = await fetch('grup-sprijin/index.json?t=' + Date.now());
if (!response.ok) throw new Error('Nu am găsit fișierul');
items = await response.json();
render();
} catch (e) {
console.error('Error loading items:', e);
document.getElementById('itemsGrid').innerHTML = `
<div class="error-msg">
Eroare la încărcare: ${e.message}<br>
<small>Verifică dacă fișierul grup-sprijin/index.json există</small>
</div>
`;
}
}
function render() {
const search = document.getElementById('searchInput').value.toLowerCase();
let filtered = items.filter(item => {
if (search && !item.title.toLowerCase().includes(search) &&
!item.content.toLowerCase().includes(search) &&
!item.tags.some(t => t.toLowerCase().includes(search))) {
return false;
}
if (currentFilter === 'all') return true;
if (currentFilter === 'used') return item.used;
if (currentFilter === 'unused') return !item.used;
return item.type === currentFilter;
});
const total = items.length;
const used = items.filter(i => i.used).length;
document.getElementById('stats').innerHTML = `
<span>Total: ${total}</span>
<span>Folosite: ${used}</span>
<span>Nefolosite: ${total - used}</span>
`;
const grid = document.getElementById('itemsGrid');
if (filtered.length === 0) {
grid.innerHTML = '<p style="color: var(--text-muted);">Niciun rezultat</p>';
return;
}
grid.innerHTML = filtered.map(item => `
<div class="item-card ${item.used ? 'used' : ''}" onclick="openModal('${item.id}')">
<div class="item-header">
<span class="item-title">${item.title}</span>
<span class="item-type ${item.type}">${item.type}</span>
</div>
<div class="item-tags">
${item.tags.map(t => `<span class="tag">${t}</span>`).join('')}
</div>
${item.used ? `<div class="item-used">✓ Folosit: ${item.used}</div>` : ''}
</div>
`).join('');
lucide.createIcons();
}
function openModal(id) {
currentItem = items.find(i => i.id === id);
if (!currentItem) return;
document.getElementById('modalTitle').textContent = currentItem.title;
document.getElementById('modalType').textContent = currentItem.type;
document.getElementById('modalType').className = `item-type ${currentItem.type}`;
document.getElementById('modalBody').textContent = currentItem.content;
document.getElementById('modalTags').innerHTML = currentItem.tags.map(t => `<span class="tag">${t}</span>`).join('');
document.getElementById('markUsedBtn').textContent = currentItem.used ? 'Marchează nefolosit' : 'Marchează folosit';
document.getElementById('modal').classList.add('open');
lucide.createIcons();
}
function closeModal() {
document.getElementById('modal').classList.remove('open');
currentItem = null;
}
async function toggleUsed() {
if (!currentItem) return;
const idx = items.findIndex(i => i.id === currentItem.id);
if (idx === -1) return;
if (items[idx].used) {
items[idx].used = null;
} else {
items[idx].used = new Date().toLocaleDateString('ro-RO');
}
try {
await fetch(`${API_BASE}/api/files`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
path: 'grup-sprijin/index.json',
content: JSON.stringify(items, null, 2)
})
});
} catch (e) {
console.error('Error saving:', e);
}
closeModal();
render();
}
document.querySelectorAll('.filter-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
currentFilter = btn.dataset.filter;
render();
});
});
document.getElementById('searchInput').addEventListener('input', render);
document.getElementById('modal').addEventListener('click', (e) => {
if (e.target.id === 'modal') closeModal();
});
// Load fise
async function loadFise() {
try {
const response = await fetch(`${API_BASE}/api/files?path=kanban/grup-sprijin&action=list`);
const data = await response.json();
if (data.items) {
const fise = data.items.filter(f => f.name.startsWith('fisa-') && f.name.endsWith('.md'));
if (fise.length > 0) {
document.getElementById('fiseSection').style.display = 'block';
document.getElementById('fiseList').innerHTML = fise.map(f => `
<a href="files.html#kanban/grup-sprijin/${f.name}" class="filter-btn" style="text-decoration: none;">
${f.name.replace('fisa-', '').replace('.md', '')}
</a>
`).join('');
}
}
} catch (e) {
console.log('No fise yet');
}
}
// Init
loadItems();
loadFise();
lucide.createIcons();
updateThemeIcon();
</script>
</body>
</html>

1839
dashboard/index.html Normal file

File diff suppressed because it is too large Load Diff

29
dashboard/issues.json Normal file
View File

@@ -0,0 +1,29 @@
{
"lastUpdated": "2026-01-30T17:37:00.676Z",
"programs": [
"ROACONT",
"ROAGEST",
"ROAIMOB",
"ROAFACTURARE",
"ROADEF",
"ROASTART",
"ROAPRINT",
"ROAWEB",
"Clawdbot",
"Personal",
"Altele"
],
"issues": [
{
"id": "ROA-001",
"title": "D101: Mutare impozit precedent RD49→RD50",
"description": "RD 49 = în urma inspecției fiscale\nRD 50 = impozit precedent\nFormularul nu recalculează impozitul de 16%\nRD 40 se modifică și la 4.1",
"program": "ROACONT",
"owner": "marius",
"priority": "urgent-important",
"status": "todo",
"created": "2026-01-30T15:10:00Z",
"deadline": null
}
]
}

1
dashboard/memory Symbolic link
View File

@@ -0,0 +1 @@
../memory

1
dashboard/notes-data Symbolic link
View File

@@ -0,0 +1 @@
../kb

1214
dashboard/notes.html Normal file

File diff suppressed because it is too large Load Diff

4
dashboard/status.json Normal file
View File

@@ -0,0 +1,4 @@
{
"git": {"status": "4 fișiere", "clean": false, "files": 4},
"lastReport": {"type": "evening", "summary": "notes.html îmbunătățit (filtre colorate), rețetă salvată", "time": "30 Jan 2026, 22:00"}
}

123
dashboard/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();
}
})();

237
dashboard/tasks.json Normal file
View File

@@ -0,0 +1,237 @@
{
"lastUpdated": "2026-01-30T20:26:37.897978Z",
"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-029",
"title": "Test sortare timestamp",
"description": "Verificare sortare",
"created": "2026-01-29T14:54:17Z",
"priority": "medium",
"completed": "2026-01-29T14:54:25Z"
},
{
"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"
},
{
"id": "task-028",
"title": "ANAF Monitor - verificare (test)",
"description": "Testare manuală cron job",
"created": "2026-01-29",
"priority": "medium",
"completed": "2026-01-29"
},
{
"id": "task-030",
"title": "Test task tracking",
"description": "",
"created": "2026-01-30T20:12:25Z",
"priority": "medium",
"completed": "2026-01-30T20:12:29Z"
},
{
"id": "task-031",
"title": "Fix notes tag coloring on expand",
"description": "",
"created": "2026-01-30T20:16:46Z",
"priority": "medium",
"completed": "2026-01-30T20:17:08Z"
},
{
"id": "task-032",
"title": "Fix cron jobs timezone Bucharest",
"description": "",
"created": "2026-01-30T20:21:26Z",
"priority": "medium",
"completed": "2026-01-30T20:21:44Z"
},
{
"id": "task-033",
"title": "Redirect coaching to @health, reports to @work",
"description": "",
"created": "2026-01-30T20:25:22Z",
"priority": "medium",
"completed": "2026-01-30T20:26:37Z"
}
]
}
]
}

120
dashboard/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.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ"),
"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.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ")
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
dashboard/youtube-notes Symbolic link
View File

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