mirror of
https://github.com/Dvorinka/Trackeep.git
synced 2026-07-29 05:53:50 +00:00
uppdate
This commit is contained in:
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"languages": {},
|
||||||
|
"review_max_age_days": 30,
|
||||||
|
"holistic_max_age_days": 30,
|
||||||
|
"generate_scorecard": true,
|
||||||
|
"badge_path": "scorecard.png",
|
||||||
|
"exclude": [],
|
||||||
|
"ignore": [
|
||||||
|
"test_coverage::frontend/src/pages/Login.tsx",
|
||||||
|
"test_coverage::frontend/src/App.tsx"
|
||||||
|
],
|
||||||
|
"ignore_metadata": {
|
||||||
|
"test_coverage::frontend/src/pages/Login.tsx": {
|
||||||
|
"note": "Login page - test coverage is separate effort, permanently ignore",
|
||||||
|
"added_at": "2026-02-18T13:23:38+00:00"
|
||||||
|
},
|
||||||
|
"test_coverage::frontend/src/App.tsx": {
|
||||||
|
"note": "Main App component - test coverage is separate effort, permanently ignore",
|
||||||
|
"added_at": "2026-02-18T13:26:59+00:00"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zone_overrides": {},
|
||||||
|
"review_dimensions": [],
|
||||||
|
"review_allow_custom_dimensions": false,
|
||||||
|
"review_custom_dimensions": [],
|
||||||
|
"large_files_threshold": 0,
|
||||||
|
"props_threshold": 0,
|
||||||
|
"finding_noise_budget": 10,
|
||||||
|
"finding_noise_global_budget": 0,
|
||||||
|
"target_strict_score": 95
|
||||||
|
}
|
||||||
@@ -0,0 +1,742 @@
|
|||||||
|
{
|
||||||
|
"command": "status",
|
||||||
|
"overall_score": 75.0,
|
||||||
|
"objective_score": 100.0,
|
||||||
|
"strict_score": 59.3,
|
||||||
|
"strict_all_detected": 59.1,
|
||||||
|
"dimension_scores": {
|
||||||
|
"File health": {
|
||||||
|
"score": 100.0,
|
||||||
|
"strict": 87.6,
|
||||||
|
"checks": 143,
|
||||||
|
"issues": 0,
|
||||||
|
"tier": 3,
|
||||||
|
"detectors": {
|
||||||
|
"structural": {
|
||||||
|
"potential": 143,
|
||||||
|
"pass_rate": 1.0,
|
||||||
|
"issues": 0,
|
||||||
|
"weighted_failures": 0.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Code quality": {
|
||||||
|
"score": 100.0,
|
||||||
|
"strict": 67.2,
|
||||||
|
"checks": 1211,
|
||||||
|
"issues": 0,
|
||||||
|
"tier": 3,
|
||||||
|
"detectors": {
|
||||||
|
"unused": {
|
||||||
|
"potential": 143,
|
||||||
|
"pass_rate": 1.0,
|
||||||
|
"issues": 0,
|
||||||
|
"weighted_failures": 0.0
|
||||||
|
},
|
||||||
|
"logs": {
|
||||||
|
"potential": 143,
|
||||||
|
"pass_rate": 1.0,
|
||||||
|
"issues": 0,
|
||||||
|
"weighted_failures": 0.0
|
||||||
|
},
|
||||||
|
"exports": {
|
||||||
|
"potential": 305,
|
||||||
|
"pass_rate": 1.0,
|
||||||
|
"issues": 0,
|
||||||
|
"weighted_failures": 0.0
|
||||||
|
},
|
||||||
|
"deprecated": {
|
||||||
|
"potential": 2,
|
||||||
|
"pass_rate": 1.0,
|
||||||
|
"issues": 0,
|
||||||
|
"weighted_failures": 0.0
|
||||||
|
},
|
||||||
|
"props": {
|
||||||
|
"potential": 76,
|
||||||
|
"pass_rate": 1.0,
|
||||||
|
"issues": 0,
|
||||||
|
"weighted_failures": 0.0
|
||||||
|
},
|
||||||
|
"smells": {
|
||||||
|
"potential": 143,
|
||||||
|
"pass_rate": 1.0,
|
||||||
|
"issues": 0,
|
||||||
|
"weighted_failures": 0.0
|
||||||
|
},
|
||||||
|
"react": {
|
||||||
|
"potential": 14,
|
||||||
|
"pass_rate": 1.0,
|
||||||
|
"issues": 0,
|
||||||
|
"weighted_failures": 0.0
|
||||||
|
},
|
||||||
|
"orphaned": {
|
||||||
|
"potential": 146,
|
||||||
|
"pass_rate": 1.0,
|
||||||
|
"issues": 0,
|
||||||
|
"weighted_failures": 0.0
|
||||||
|
},
|
||||||
|
"flat_dirs": {
|
||||||
|
"potential": 25,
|
||||||
|
"pass_rate": 1.0,
|
||||||
|
"issues": 0,
|
||||||
|
"weighted_failures": 0.0
|
||||||
|
},
|
||||||
|
"naming": {
|
||||||
|
"potential": 23,
|
||||||
|
"pass_rate": 1.0,
|
||||||
|
"issues": 0,
|
||||||
|
"weighted_failures": 0.0
|
||||||
|
},
|
||||||
|
"facade": {
|
||||||
|
"potential": 146,
|
||||||
|
"pass_rate": 1.0,
|
||||||
|
"issues": 0,
|
||||||
|
"weighted_failures": 0.0
|
||||||
|
},
|
||||||
|
"patterns": {
|
||||||
|
"potential": 3,
|
||||||
|
"pass_rate": 1.0,
|
||||||
|
"issues": 0,
|
||||||
|
"weighted_failures": 0.0
|
||||||
|
},
|
||||||
|
"single_use": {
|
||||||
|
"potential": 42,
|
||||||
|
"pass_rate": 1.0,
|
||||||
|
"issues": 0,
|
||||||
|
"weighted_failures": 0.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Duplication": {
|
||||||
|
"score": 100.0,
|
||||||
|
"strict": 99.4,
|
||||||
|
"checks": 288,
|
||||||
|
"issues": 0,
|
||||||
|
"tier": 3,
|
||||||
|
"detectors": {
|
||||||
|
"dupes": {
|
||||||
|
"potential": 288,
|
||||||
|
"pass_rate": 1.0,
|
||||||
|
"issues": 0,
|
||||||
|
"weighted_failures": 0.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Test health": {
|
||||||
|
"score": 100.0,
|
||||||
|
"strict": 48.6,
|
||||||
|
"checks": 2246,
|
||||||
|
"issues": 0,
|
||||||
|
"tier": 4,
|
||||||
|
"detectors": {
|
||||||
|
"test_coverage": {
|
||||||
|
"potential": 2109,
|
||||||
|
"pass_rate": 1.0,
|
||||||
|
"issues": 0,
|
||||||
|
"weighted_failures": 0.0
|
||||||
|
},
|
||||||
|
"subjective_review": {
|
||||||
|
"potential": 137,
|
||||||
|
"pass_rate": 1.0,
|
||||||
|
"issues": 0,
|
||||||
|
"weighted_failures": 0.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Security": {
|
||||||
|
"score": 100.0,
|
||||||
|
"strict": 98.6,
|
||||||
|
"checks": 289,
|
||||||
|
"issues": 0,
|
||||||
|
"tier": 4,
|
||||||
|
"detectors": {
|
||||||
|
"security": {
|
||||||
|
"potential": 143,
|
||||||
|
"pass_rate": 1.0,
|
||||||
|
"issues": 0,
|
||||||
|
"weighted_failures": 0.0
|
||||||
|
},
|
||||||
|
"cycles": {
|
||||||
|
"potential": 146,
|
||||||
|
"pass_rate": 1.0,
|
||||||
|
"issues": 0,
|
||||||
|
"weighted_failures": 0.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Naming Quality": {
|
||||||
|
"score": 0.0,
|
||||||
|
"strict": 0.0,
|
||||||
|
"checks": 10,
|
||||||
|
"issues": 0,
|
||||||
|
"tier": 4,
|
||||||
|
"detectors": {
|
||||||
|
"subjective_assessment": {
|
||||||
|
"potential": 10,
|
||||||
|
"pass_rate": 0.0,
|
||||||
|
"issues": 0,
|
||||||
|
"weighted_failures": 10.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Error Consistency": {
|
||||||
|
"score": 0.0,
|
||||||
|
"strict": 0.0,
|
||||||
|
"checks": 10,
|
||||||
|
"issues": 0,
|
||||||
|
"tier": 4,
|
||||||
|
"detectors": {
|
||||||
|
"subjective_assessment": {
|
||||||
|
"potential": 10,
|
||||||
|
"pass_rate": 0.0,
|
||||||
|
"issues": 0,
|
||||||
|
"weighted_failures": 10.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Abstraction Fit": {
|
||||||
|
"score": 0.0,
|
||||||
|
"strict": 0.0,
|
||||||
|
"checks": 10,
|
||||||
|
"issues": 0,
|
||||||
|
"tier": 4,
|
||||||
|
"detectors": {
|
||||||
|
"subjective_assessment": {
|
||||||
|
"potential": 10,
|
||||||
|
"pass_rate": 0.0,
|
||||||
|
"issues": 0,
|
||||||
|
"weighted_failures": 10.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Logic Clarity": {
|
||||||
|
"score": 0.0,
|
||||||
|
"strict": 0.0,
|
||||||
|
"checks": 10,
|
||||||
|
"issues": 0,
|
||||||
|
"tier": 4,
|
||||||
|
"detectors": {
|
||||||
|
"subjective_assessment": {
|
||||||
|
"potential": 10,
|
||||||
|
"pass_rate": 0.0,
|
||||||
|
"issues": 0,
|
||||||
|
"weighted_failures": 10.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"AI Generated Debt": {
|
||||||
|
"score": 0.0,
|
||||||
|
"strict": 0.0,
|
||||||
|
"checks": 10,
|
||||||
|
"issues": 0,
|
||||||
|
"tier": 4,
|
||||||
|
"detectors": {
|
||||||
|
"subjective_assessment": {
|
||||||
|
"potential": 10,
|
||||||
|
"pass_rate": 0.0,
|
||||||
|
"issues": 0,
|
||||||
|
"weighted_failures": 10.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Type Safety": {
|
||||||
|
"score": 0.0,
|
||||||
|
"strict": 0.0,
|
||||||
|
"checks": 10,
|
||||||
|
"issues": 0,
|
||||||
|
"tier": 4,
|
||||||
|
"detectors": {
|
||||||
|
"subjective_assessment": {
|
||||||
|
"potential": 10,
|
||||||
|
"pass_rate": 0.0,
|
||||||
|
"issues": 0,
|
||||||
|
"weighted_failures": 10.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Contract Coherence": {
|
||||||
|
"score": 0.0,
|
||||||
|
"strict": 0.0,
|
||||||
|
"checks": 10,
|
||||||
|
"issues": 0,
|
||||||
|
"tier": 4,
|
||||||
|
"detectors": {
|
||||||
|
"subjective_assessment": {
|
||||||
|
"potential": 10,
|
||||||
|
"pass_rate": 0.0,
|
||||||
|
"issues": 0,
|
||||||
|
"weighted_failures": 10.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"stats": {
|
||||||
|
"total": 873,
|
||||||
|
"open": 0,
|
||||||
|
"fixed": 20,
|
||||||
|
"auto_resolved": 1,
|
||||||
|
"wontfix": 768,
|
||||||
|
"false_positive": 84,
|
||||||
|
"by_tier": {
|
||||||
|
"1": {
|
||||||
|
"open": 0,
|
||||||
|
"fixed": 17,
|
||||||
|
"auto_resolved": 0,
|
||||||
|
"wontfix": 8,
|
||||||
|
"false_positive": 0
|
||||||
|
},
|
||||||
|
"2": {
|
||||||
|
"open": 0,
|
||||||
|
"fixed": 3,
|
||||||
|
"auto_resolved": 1,
|
||||||
|
"wontfix": 376,
|
||||||
|
"false_positive": 26
|
||||||
|
},
|
||||||
|
"3": {
|
||||||
|
"open": 0,
|
||||||
|
"fixed": 0,
|
||||||
|
"auto_resolved": 0,
|
||||||
|
"wontfix": 245,
|
||||||
|
"false_positive": 58
|
||||||
|
},
|
||||||
|
"4": {
|
||||||
|
"open": 0,
|
||||||
|
"fixed": 0,
|
||||||
|
"auto_resolved": 0,
|
||||||
|
"wontfix": 139,
|
||||||
|
"false_positive": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scan_count": 10,
|
||||||
|
"last_scan": "2026-02-18T13:28:26+00:00",
|
||||||
|
"by_tier": {
|
||||||
|
"1": {
|
||||||
|
"open": 0,
|
||||||
|
"fixed": 17,
|
||||||
|
"auto_resolved": 0,
|
||||||
|
"wontfix": 8,
|
||||||
|
"false_positive": 0
|
||||||
|
},
|
||||||
|
"2": {
|
||||||
|
"open": 0,
|
||||||
|
"fixed": 3,
|
||||||
|
"auto_resolved": 1,
|
||||||
|
"wontfix": 376,
|
||||||
|
"false_positive": 26
|
||||||
|
},
|
||||||
|
"3": {
|
||||||
|
"open": 0,
|
||||||
|
"fixed": 0,
|
||||||
|
"auto_resolved": 0,
|
||||||
|
"wontfix": 245,
|
||||||
|
"false_positive": 58
|
||||||
|
},
|
||||||
|
"4": {
|
||||||
|
"open": 0,
|
||||||
|
"fixed": 0,
|
||||||
|
"auto_resolved": 0,
|
||||||
|
"wontfix": 139,
|
||||||
|
"false_positive": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ignores": [
|
||||||
|
"test_coverage::frontend/src/pages/Login.tsx",
|
||||||
|
"test_coverage::frontend/src/App.tsx"
|
||||||
|
],
|
||||||
|
"suppression": {
|
||||||
|
"last_ignored": 1,
|
||||||
|
"last_raw_findings": 853,
|
||||||
|
"last_suppressed_pct": 0.1,
|
||||||
|
"last_ignore_patterns": 2,
|
||||||
|
"recent_scans": 5,
|
||||||
|
"recent_ignored": 1,
|
||||||
|
"recent_raw_findings": 4265,
|
||||||
|
"recent_suppressed_pct": 0.0
|
||||||
|
},
|
||||||
|
"detector_transparency": {
|
||||||
|
"rows": [
|
||||||
|
{
|
||||||
|
"detector": "exports",
|
||||||
|
"visible": 305,
|
||||||
|
"suppressed": 0,
|
||||||
|
"excluded": 0,
|
||||||
|
"total_detected": 305
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"detector": "smells",
|
||||||
|
"visible": 215,
|
||||||
|
"suppressed": 0,
|
||||||
|
"excluded": 1,
|
||||||
|
"total_detected": 216
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"detector": "subjective_review",
|
||||||
|
"visible": 138,
|
||||||
|
"suppressed": 0,
|
||||||
|
"excluded": 0,
|
||||||
|
"total_detected": 138
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"detector": "test_coverage",
|
||||||
|
"visible": 49,
|
||||||
|
"suppressed": 1,
|
||||||
|
"excluded": 0,
|
||||||
|
"total_detected": 50
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"detector": "structural",
|
||||||
|
"visible": 25,
|
||||||
|
"suppressed": 0,
|
||||||
|
"excluded": 0,
|
||||||
|
"total_detected": 25
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"detector": "security",
|
||||||
|
"visible": 18,
|
||||||
|
"suppressed": 0,
|
||||||
|
"excluded": 0,
|
||||||
|
"total_detected": 18
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"detector": "logs",
|
||||||
|
"visible": 6,
|
||||||
|
"suppressed": 0,
|
||||||
|
"excluded": 0,
|
||||||
|
"total_detected": 6
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"detector": "dupes",
|
||||||
|
"visible": 3,
|
||||||
|
"suppressed": 0,
|
||||||
|
"excluded": 0,
|
||||||
|
"total_detected": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"detector": "deprecated",
|
||||||
|
"visible": 0,
|
||||||
|
"suppressed": 0,
|
||||||
|
"excluded": 2,
|
||||||
|
"total_detected": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"detector": "flat_dirs",
|
||||||
|
"visible": 2,
|
||||||
|
"suppressed": 0,
|
||||||
|
"excluded": 0,
|
||||||
|
"total_detected": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"detector": "unused",
|
||||||
|
"visible": 2,
|
||||||
|
"suppressed": 0,
|
||||||
|
"excluded": 0,
|
||||||
|
"total_detected": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"detector": "cycles",
|
||||||
|
"visible": 1,
|
||||||
|
"suppressed": 0,
|
||||||
|
"excluded": 0,
|
||||||
|
"total_detected": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"detector": "react",
|
||||||
|
"visible": 1,
|
||||||
|
"suppressed": 0,
|
||||||
|
"excluded": 0,
|
||||||
|
"total_detected": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"totals": {
|
||||||
|
"visible": 765,
|
||||||
|
"suppressed": 1,
|
||||||
|
"excluded": 3,
|
||||||
|
"detectors": 13
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"potentials": {
|
||||||
|
"typescript": {
|
||||||
|
"logs": 143,
|
||||||
|
"unused": 143,
|
||||||
|
"exports": 305,
|
||||||
|
"deprecated": 2,
|
||||||
|
"structural": 143,
|
||||||
|
"flat_dirs": 25,
|
||||||
|
"props": 76,
|
||||||
|
"single_use": 42,
|
||||||
|
"coupling": 0,
|
||||||
|
"cycles": 146,
|
||||||
|
"orphaned": 146,
|
||||||
|
"patterns": 3,
|
||||||
|
"naming": 23,
|
||||||
|
"facade": 146,
|
||||||
|
"test_coverage": 2109,
|
||||||
|
"smells": 143,
|
||||||
|
"react": 14,
|
||||||
|
"security": 143,
|
||||||
|
"subjective_review": 137,
|
||||||
|
"dupes": 288
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"codebase_metrics": {
|
||||||
|
"typescript": {
|
||||||
|
"total_files": 151,
|
||||||
|
"total_loc": 40054,
|
||||||
|
"total_directories": 25
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"strict_target": {
|
||||||
|
"target": 95.0,
|
||||||
|
"current": 59.3,
|
||||||
|
"gap": 35.7,
|
||||||
|
"state": "below",
|
||||||
|
"warning": null
|
||||||
|
},
|
||||||
|
"narrative": {
|
||||||
|
"phase": "stagnation",
|
||||||
|
"headline": "All T1 and T2 items cleared!",
|
||||||
|
"dimensions": {
|
||||||
|
"lowest_dimensions": [
|
||||||
|
{
|
||||||
|
"name": "Naming Quality",
|
||||||
|
"strict": 0.0,
|
||||||
|
"issues": 0,
|
||||||
|
"impact": 0.0,
|
||||||
|
"subjective": true,
|
||||||
|
"impact_description": "re-review to improve"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Error Consistency",
|
||||||
|
"strict": 0.0,
|
||||||
|
"issues": 0,
|
||||||
|
"impact": 0.0,
|
||||||
|
"subjective": true,
|
||||||
|
"impact_description": "re-review to improve"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Abstraction Fit",
|
||||||
|
"strict": 0.0,
|
||||||
|
"issues": 0,
|
||||||
|
"impact": 0.0,
|
||||||
|
"subjective": true,
|
||||||
|
"impact_description": "re-review to improve"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"biggest_gap_dimensions": [
|
||||||
|
{
|
||||||
|
"name": "Test health",
|
||||||
|
"lenient": 100.0,
|
||||||
|
"strict": 48.6,
|
||||||
|
"gap": 51.4,
|
||||||
|
"wontfix_count": 187
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Code quality",
|
||||||
|
"lenient": 100.0,
|
||||||
|
"strict": 67.2,
|
||||||
|
"gap": 32.8,
|
||||||
|
"wontfix_count": 534
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "File health",
|
||||||
|
"lenient": 100.0,
|
||||||
|
"strict": 87.6,
|
||||||
|
"gap": 12.4,
|
||||||
|
"wontfix_count": 25
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"stagnant_dimensions": [
|
||||||
|
{
|
||||||
|
"name": "File health",
|
||||||
|
"strict": 87.6,
|
||||||
|
"stuck_scans": 5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Code quality",
|
||||||
|
"strict": 67.2,
|
||||||
|
"stuck_scans": 5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Duplication",
|
||||||
|
"strict": 99.4,
|
||||||
|
"stuck_scans": 5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Security",
|
||||||
|
"strict": 98.6,
|
||||||
|
"stuck_scans": 5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Naming Quality",
|
||||||
|
"strict": 0.0,
|
||||||
|
"stuck_scans": 5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Error Consistency",
|
||||||
|
"strict": 0.0,
|
||||||
|
"stuck_scans": 5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Abstraction Fit",
|
||||||
|
"strict": 0.0,
|
||||||
|
"stuck_scans": 5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Logic Clarity",
|
||||||
|
"strict": 0.0,
|
||||||
|
"stuck_scans": 5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "AI Generated Debt",
|
||||||
|
"strict": 0.0,
|
||||||
|
"stuck_scans": 5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Type Safety",
|
||||||
|
"strict": 0.0,
|
||||||
|
"stuck_scans": 5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Contract Coherence",
|
||||||
|
"strict": 0.0,
|
||||||
|
"stuck_scans": 5
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"priority": 1,
|
||||||
|
"type": "debt_review",
|
||||||
|
"detector": null,
|
||||||
|
"description": "7.8 pts of wontfix debt \u2014 review stale decisions",
|
||||||
|
"command": "desloppify show --status wontfix",
|
||||||
|
"gap": 7.8,
|
||||||
|
"lane": "debt_review"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"strategy": {
|
||||||
|
"fixer_leverage": {
|
||||||
|
"auto_fixable_count": 0,
|
||||||
|
"total_count": 0,
|
||||||
|
"coverage": 0.0,
|
||||||
|
"impact_ratio": 0.0,
|
||||||
|
"recommendation": "none"
|
||||||
|
},
|
||||||
|
"lanes": {
|
||||||
|
"debt_review": {
|
||||||
|
"actions": [
|
||||||
|
1
|
||||||
|
],
|
||||||
|
"file_count": 0,
|
||||||
|
"total_impact": 0.0,
|
||||||
|
"automation": "manual",
|
||||||
|
"run_first": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"can_parallelize": false,
|
||||||
|
"hint": "Try a different dimension to break the plateau."
|
||||||
|
},
|
||||||
|
"tools": {
|
||||||
|
"fixers": [],
|
||||||
|
"move": {
|
||||||
|
"available": true,
|
||||||
|
"relevant": false,
|
||||||
|
"reason": null,
|
||||||
|
"usage": "desloppify move <source> <dest> [--dry-run]"
|
||||||
|
},
|
||||||
|
"plan": {
|
||||||
|
"command": "desloppify plan",
|
||||||
|
"description": "Generate prioritized markdown cleanup plan"
|
||||||
|
},
|
||||||
|
"badge": {
|
||||||
|
"generated": true,
|
||||||
|
"in_readme": true,
|
||||||
|
"path": "scorecard.png",
|
||||||
|
"recommendation": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"debt": {
|
||||||
|
"overall_gap": 7.8,
|
||||||
|
"wontfix_count": 768,
|
||||||
|
"worst_dimension": "Test health",
|
||||||
|
"worst_gap": 51.4,
|
||||||
|
"trend": "stable"
|
||||||
|
},
|
||||||
|
"milestone": "All T1 and T2 items cleared!",
|
||||||
|
"primary_action": {
|
||||||
|
"priority": 1,
|
||||||
|
"type": "debt_review",
|
||||||
|
"detector": null,
|
||||||
|
"command": "desloppify show --status wontfix",
|
||||||
|
"description": "7.8 pts of wontfix debt \u2014 review stale decisions",
|
||||||
|
"impact": null,
|
||||||
|
"lane": "debt_review",
|
||||||
|
"count": null
|
||||||
|
},
|
||||||
|
"why_now": "Progress is plateaued, so the top action is the best chance to break the plateau.",
|
||||||
|
"verification_step": {
|
||||||
|
"command": "desloppify show --status wontfix",
|
||||||
|
"reason": "Re-check stale wontfix decisions before treating strict score as stable.",
|
||||||
|
"success_signal": "Wontfix list reflects only intentional and still-valid exceptions."
|
||||||
|
},
|
||||||
|
"risk_flags": [
|
||||||
|
{
|
||||||
|
"type": "wontfix_gap",
|
||||||
|
"severity": "medium",
|
||||||
|
"message": "7.8 strict-score points are masked by wontfix debt (768 items).",
|
||||||
|
"command": "desloppify show --status wontfix"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"strict_target": {
|
||||||
|
"target": 95.0,
|
||||||
|
"current": 59.3,
|
||||||
|
"gap": 35.7,
|
||||||
|
"state": "below",
|
||||||
|
"warning": null
|
||||||
|
},
|
||||||
|
"reminders": [],
|
||||||
|
"reminder_history": {
|
||||||
|
"report_scores": 10,
|
||||||
|
"auto_fixers_available": 3,
|
||||||
|
"dry_run_first": 3,
|
||||||
|
"zone_classification": 3,
|
||||||
|
"feedback_nudge": 3,
|
||||||
|
"stagnant_nudge": 10,
|
||||||
|
"fp_calibration_security_production": 3,
|
||||||
|
"wontfix_growing": 3,
|
||||||
|
"fp_calibration_orphaned_production": 3
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"review_max_age_days": 30,
|
||||||
|
"holistic_max_age_days": 30,
|
||||||
|
"generate_scorecard": true,
|
||||||
|
"badge_path": "scorecard.png",
|
||||||
|
"exclude": [],
|
||||||
|
"ignore": [
|
||||||
|
"test_coverage::frontend/src/pages/Login.tsx",
|
||||||
|
"test_coverage::frontend/src/App.tsx"
|
||||||
|
],
|
||||||
|
"ignore_metadata": {
|
||||||
|
"test_coverage::frontend/src/pages/Login.tsx": {
|
||||||
|
"note": "Login page - test coverage is separate effort, permanently ignore",
|
||||||
|
"added_at": "2026-02-18T13:23:38+00:00"
|
||||||
|
},
|
||||||
|
"test_coverage::frontend/src/App.tsx": {
|
||||||
|
"note": "Main App component - test coverage is separate effort, permanently ignore",
|
||||||
|
"added_at": "2026-02-18T13:26:59+00:00"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zone_overrides": {},
|
||||||
|
"review_dimensions": [],
|
||||||
|
"review_allow_custom_dimensions": false,
|
||||||
|
"review_custom_dimensions": [],
|
||||||
|
"large_files_threshold": 0,
|
||||||
|
"props_threshold": 0,
|
||||||
|
"finding_noise_budget": 10,
|
||||||
|
"finding_noise_global_budget": 0,
|
||||||
|
"target_strict_score": 95,
|
||||||
|
"languages": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,10 @@
|
|||||||
# Server Configuration
|
# Server Configuration
|
||||||
PORT=8080
|
PORT=8080
|
||||||
GIN_MODE=debug
|
GIN_MODE=debug
|
||||||
|
READ_TIMEOUT=15s
|
||||||
|
WRITE_TIMEOUT=15s
|
||||||
|
IDLE_TIMEOUT=60s
|
||||||
|
SHUTDOWN_TIMEOUT=30s
|
||||||
|
|
||||||
# Database Configuration
|
# Database Configuration
|
||||||
DB_TYPE=postgres
|
DB_TYPE=postgres
|
||||||
@@ -11,6 +15,11 @@ DB_PASSWORD=your_password_here
|
|||||||
DB_NAME=trackeep
|
DB_NAME=trackeep
|
||||||
DB_SSL_MODE=disable
|
DB_SSL_MODE=disable
|
||||||
|
|
||||||
|
# Docker Compose Database (used by docker-compose.yml)
|
||||||
|
POSTGRES_DB=trackeep
|
||||||
|
POSTGRES_USER=trackeep
|
||||||
|
POSTGRES_PASSWORD=your_secure_password_here
|
||||||
|
|
||||||
# JWT Configuration
|
# JWT Configuration
|
||||||
# JWT_SECRET is auto-generated on startup and stored in jwt_secret.key
|
# JWT_SECRET is auto-generated on startup and stored in jwt_secret.key
|
||||||
# You can override by setting JWT_SECRET environment variable if needed
|
# You can override by setting JWT_SECRET environment variable if needed
|
||||||
@@ -84,3 +93,20 @@ SEARCH_API_PROVIDER=brave # Options: brave, serper, demo
|
|||||||
SEARCH_RESULTS_LIMIT=10
|
SEARCH_RESULTS_LIMIT=10
|
||||||
SEARCH_CACHE_TTL=300
|
SEARCH_CACHE_TTL=300
|
||||||
SEARCH_RATE_LIMIT=100
|
SEARCH_RATE_LIMIT=100
|
||||||
|
|
||||||
|
# Update Configuration
|
||||||
|
# Application version (used for update checking)
|
||||||
|
APP_VERSION=1.0.0
|
||||||
|
|
||||||
|
# OAuth service configuration (REQUIRED)
|
||||||
|
# The OAuth service must be running for updates to work
|
||||||
|
OAUTH_SERVICE_URL=http://localhost:9090
|
||||||
|
JWT_SECRET=your-jwt-secret-key
|
||||||
|
|
||||||
|
# Update settings
|
||||||
|
AUTO_UPDATE_CHECK=false
|
||||||
|
UPDATE_CHECK_INTERVAL=24h
|
||||||
|
PRERELEASE_UPDATES=false
|
||||||
|
|
||||||
|
# Note: No GitHub token configuration needed
|
||||||
|
# Updates use the OAuth service which handles GitHub authentication automatically
|
||||||
|
|||||||
+21
-16
@@ -36,22 +36,22 @@ jobs:
|
|||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v4
|
uses: actions/setup-go@v4
|
||||||
with:
|
with:
|
||||||
go-version: '1.21'
|
go-version: '1.24'
|
||||||
|
|
||||||
- name: Set up Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '18'
|
|
||||||
cache: 'npm'
|
|
||||||
cache-dependency-path: |
|
|
||||||
frontend/package-lock.json
|
|
||||||
landing/package-lock.json
|
|
||||||
|
|
||||||
- name: Install backend dependencies
|
- name: Install backend dependencies
|
||||||
run: |
|
run: |
|
||||||
cd backend
|
cd backend
|
||||||
go mod download
|
go mod download
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
cache: 'npm'
|
||||||
|
cache-dependency-path: |
|
||||||
|
frontend/package.json
|
||||||
|
landing/package.json
|
||||||
|
|
||||||
- name: Run backend tests
|
- name: Run backend tests
|
||||||
run: |
|
run: |
|
||||||
cd backend
|
cd backend
|
||||||
@@ -60,7 +60,7 @@ jobs:
|
|||||||
DATABASE_URL: postgres://postgres:postgres@localhost:5432/trackeep_test?sslmode=disable
|
DATABASE_URL: postgres://postgres:postgres@localhost:5432/trackeep_test?sslmode=disable
|
||||||
|
|
||||||
- name: Upload coverage to Codecov
|
- name: Upload coverage to Codecov
|
||||||
uses: codecov/codecov-action@v3
|
uses: codecov/codecov-action@v4
|
||||||
with:
|
with:
|
||||||
file: ./backend/coverage.out
|
file: ./backend/coverage.out
|
||||||
flags: backend
|
flags: backend
|
||||||
@@ -87,13 +87,18 @@ jobs:
|
|||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Run Gosec Security Scanner
|
- name: Set up Go
|
||||||
uses: securecodewarrior/github-action-gosec@v1
|
uses: actions/setup-go@v4
|
||||||
with:
|
with:
|
||||||
args: '-no-fail -fmt sarif -out results.sarif ./...'
|
go-version: '1.24'
|
||||||
|
|
||||||
|
- name: Run Gosec Security Scanner
|
||||||
|
run: |
|
||||||
|
go install github.com/securecodewarrior/gosec/v2/cmd/gosec@latest
|
||||||
|
gosec -no-fail -fmt sarif -out results.sarif ./...
|
||||||
|
|
||||||
- name: Upload SARIF file
|
- name: Upload SARIF file
|
||||||
uses: github/codeql-action/upload-sarif@v2
|
uses: github/codeql-action/upload-sarif@v3
|
||||||
with:
|
with:
|
||||||
sarif_file: results.sarif
|
sarif_file: results.sarif
|
||||||
|
|
||||||
@@ -117,7 +122,7 @@ jobs:
|
|||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Log in to Container Registry
|
- name: Log in to Container Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.REGISTRY }}
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { NavigationContainer } from '@react-navigation/native';
|
|
||||||
import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
||||||
import { useAuth } from '../services/AuthContext';
|
import { useAuth } from '../services/AuthContext';
|
||||||
import { useServerConfig } from '../services/ServerConfigContext';
|
import { useServerConfig } from '../services/ServerConfigContext';
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
||||||
import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
|
||||||
import Icon from 'react-native-vector-icons/MaterialCommunityIcons';
|
import Icon from 'react-native-vector-icons/MaterialCommunityIcons';
|
||||||
import { useOffline } from '../services/OfflineContext';
|
import { useOffline } from '../services/OfflineContext';
|
||||||
import { useTheme } from 'react-native-paper';
|
import { useTheme } from 'react-native-paper';
|
||||||
@@ -28,13 +27,9 @@ export type MainTabParamList = {
|
|||||||
const Tab = createBottomTabNavigator<MainTabParamList>();
|
const Tab = createBottomTabNavigator<MainTabParamList>();
|
||||||
|
|
||||||
const TabNavigator: React.FC = () => {
|
const TabNavigator: React.FC = () => {
|
||||||
const { isOnline, pendingChanges } = useOffline();
|
const { pendingChanges } = useOffline();
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
|
||||||
const getTabBarIcon = (name: string, color: string) => (
|
|
||||||
<Icon name={name} size={24} color={color} />
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tab.Navigator
|
<Tab.Navigator
|
||||||
screenOptions={({ route }) => ({
|
screenOptions={({ route }) => ({
|
||||||
|
|||||||
@@ -8,16 +8,12 @@ import {
|
|||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import {
|
import {
|
||||||
Text,
|
Text,
|
||||||
Card,
|
|
||||||
Title,
|
Title,
|
||||||
Paragraph,
|
Paragraph,
|
||||||
TextInput,
|
TextInput,
|
||||||
Button,
|
Button,
|
||||||
FAB,
|
|
||||||
IconButton,
|
|
||||||
Avatar,
|
Avatar,
|
||||||
Chip,
|
Chip,
|
||||||
Divider,
|
|
||||||
} from 'react-native-paper';
|
} from 'react-native-paper';
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
import { useRealtimeUpdates } from '../services/RealtimeSyncContext';
|
import { useRealtimeUpdates } from '../services/RealtimeSyncContext';
|
||||||
|
|||||||
@@ -7,19 +7,16 @@ import {
|
|||||||
Alert,
|
Alert,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import {
|
import {
|
||||||
Text,
|
|
||||||
Card,
|
Card,
|
||||||
Title,
|
Title,
|
||||||
Paragraph,
|
Paragraph,
|
||||||
TextInput,
|
TextInput,
|
||||||
Button,
|
Button,
|
||||||
ActivityIndicator,
|
|
||||||
HelperText,
|
HelperText,
|
||||||
} from 'react-native-paper';
|
} from 'react-native-paper';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { useServerConfig } from '../services/ServerConfigContext';
|
import { useServerConfig } from '../services/ServerConfigContext';
|
||||||
import { updateAPIBaseURL } from '../services/api';
|
import { updateAPIBaseURL } from '../services/api';
|
||||||
import { useNavigation } from '@react-navigation/native';
|
|
||||||
|
|
||||||
interface ServerConfig {
|
interface ServerConfig {
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
@@ -37,7 +34,6 @@ const ServerSetupScreen: React.FC = () => {
|
|||||||
const [errors, setErrors] = useState<Partial<ServerConfig>>({});
|
const [errors, setErrors] = useState<Partial<ServerConfig>>({});
|
||||||
|
|
||||||
const { setConfig: saveConfig } = useServerConfig();
|
const { setConfig: saveConfig } = useServerConfig();
|
||||||
const navigation = useNavigation();
|
|
||||||
|
|
||||||
const validateConfig = (): boolean => {
|
const validateConfig = (): boolean => {
|
||||||
const newErrors: Partial<ServerConfig> = {};
|
const newErrors: Partial<ServerConfig> = {};
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
import {
|
import {
|
||||||
TextInput,
|
TextInput,
|
||||||
Button,
|
Button,
|
||||||
Text,
|
|
||||||
Card,
|
Card,
|
||||||
Title,
|
Title,
|
||||||
Paragraph,
|
Paragraph,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||||
import { View, Alert, Platform } from 'react-native';
|
import { Alert, Platform } from 'react-native';
|
||||||
import { Camera, useCameraDevices } from 'react-native-vision-camera';
|
import { useCameraDevices } from 'react-native-vision-camera';
|
||||||
import { request, PERMISSIONS, RESULTS } from 'react-native-permissions';
|
import { request, PERMISSIONS, RESULTS } from 'react-native-permissions';
|
||||||
|
|
||||||
interface CameraContextType {
|
interface CameraContextType {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||||
import PushNotification from 'react-native-push-notification';
|
import PushNotification from 'react-native-push-notification';
|
||||||
import { Platform, PermissionsAndroid, Alert } from 'react-native';
|
import { Platform, Alert } from 'react-native';
|
||||||
import { request, PERMISSIONS, RESULTS } from 'react-native-permissions';
|
import { request, PERMISSIONS, RESULTS } from 'react-native-permissions';
|
||||||
|
|
||||||
interface Notification {
|
interface Notification {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { createContext, useContext, useState, useEffect, ReactNode, useCallback } from 'react';
|
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||||
import { NetInfoState, useNetInfo } from '@react-native-community/netinfo';
|
import { useNetInfo } from '@react-native-community/netinfo';
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
import { useServerConfig } from './ServerConfigContext';
|
import { useServerConfig } from './ServerConfigContext';
|
||||||
import { DeviceEventEmitter } from 'react-native';
|
import { DeviceEventEmitter } from 'react-native';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||||
import { Alert, Platform, PermissionsAndroid } from 'react-native';
|
import { Alert, Platform } from 'react-native';
|
||||||
import { request, PERMISSIONS, RESULTS } from 'react-native-permissions';
|
import { request, PERMISSIONS, RESULTS } from 'react-native-permissions';
|
||||||
import Voice from 'react-native-voice';
|
import Voice from 'react-native-voice';
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import axios, { AxiosInstance, AxiosRequestConfig } from 'axios';
|
import axios, { AxiosInstance, AxiosRequestConfig } from 'axios';
|
||||||
import { ApiResponse, User, Bookmark, Task, Note, TimeEntry, CalendarEvent, SearchFilters, SavedSearch } from '../types';
|
import { ApiResponse, User, Bookmark, Task, Note, TimeEntry, CalendarEvent, SearchFilters, SavedSearch } from '../types';
|
||||||
import { getStoredAuthData } from '../utils/storage';
|
import { getStoredAuthData } from '../utils/storage';
|
||||||
import { useServerConfig } from './ServerConfigContext';
|
|
||||||
|
|
||||||
let API_BASE_URL = __DEV__
|
let API_BASE_URL = __DEV__
|
||||||
? 'http://localhost:8080/api'
|
? 'http://localhost:8080/api'
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { getOfflineData, clearOfflineChanges, addOfflineChange } from './storage';
|
import { getOfflineData, clearOfflineChanges, addOfflineChange } from './storage';
|
||||||
import { authAPI, bookmarksAPI, tasksAPI, notesAPI, timeEntriesAPI } from '../services/api';
|
import { bookmarksAPI, tasksAPI, notesAPI, timeEntriesAPI } from '../services/api';
|
||||||
|
|
||||||
interface OfflineChange {
|
interface OfflineChange {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -4,12 +4,13 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
cryptorand "crypto/rand"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
|
"math/rand"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
@@ -279,6 +280,15 @@ func main() {
|
|||||||
courses.POST("/:id/resources", addCourseResource) // Admin/Instructor only
|
courses.POST("/:id/resources", addCourseResource) // Admin/Instructor only
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Learning paths (alias for courses with learning path specific endpoints)
|
||||||
|
learningPaths := api.Group("/learning-paths")
|
||||||
|
{
|
||||||
|
learningPaths.GET("", getLearningPaths)
|
||||||
|
learningPaths.GET("/categories", getLearningPathCategories)
|
||||||
|
learningPaths.POST("/:id/enroll", enrollInLearningPath)
|
||||||
|
learningPaths.GET("/:id", getLearningPath)
|
||||||
|
}
|
||||||
|
|
||||||
// User progress
|
// User progress
|
||||||
progress := api.Group("/progress")
|
progress := api.Group("/progress")
|
||||||
{
|
{
|
||||||
@@ -781,14 +791,14 @@ func generateJWTWithEmailToken(userSession map[string]interface{}) string {
|
|||||||
|
|
||||||
func generateRandomString(length int) string {
|
func generateRandomString(length int) string {
|
||||||
bytes := make([]byte, length)
|
bytes := make([]byte, length)
|
||||||
rand.Read(bytes)
|
cryptorand.Read(bytes)
|
||||||
return hex.EncodeToString(bytes)
|
return hex.EncodeToString(bytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
func generateVerificationCode() string {
|
func generateVerificationCode() string {
|
||||||
// Generate 6-digit verification code
|
// Generate 6-digit verification code
|
||||||
codeBytes := make([]byte, 3)
|
codeBytes := make([]byte, 3)
|
||||||
rand.Read(codeBytes)
|
cryptorand.Read(codeBytes)
|
||||||
code := int(codeBytes[0])*10000 + int(codeBytes[1])*100 + int(codeBytes[2])
|
code := int(codeBytes[0])*10000 + int(codeBytes[1])*100 + int(codeBytes[2])
|
||||||
code = (code % 900000) + 100000
|
code = (code % 900000) + 100000
|
||||||
return fmt.Sprintf("%06d", code)
|
return fmt.Sprintf("%06d", code)
|
||||||
@@ -988,6 +998,210 @@ func addCourseResource(c *gin.Context) {
|
|||||||
c.JSON(http.StatusCreated, resource)
|
c.JSON(http.StatusCreated, resource)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Learning Paths Handlers (frontend-specific format)
|
||||||
|
|
||||||
|
func getLearningPaths(c *gin.Context) {
|
||||||
|
// Get query parameters
|
||||||
|
search := c.Query("search")
|
||||||
|
category := c.Query("category")
|
||||||
|
difficulty := c.Query("difficulty")
|
||||||
|
|
||||||
|
var learningPaths []gin.H
|
||||||
|
|
||||||
|
for _, course := range courses {
|
||||||
|
if !course.IsActive {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply filters
|
||||||
|
if search != "" && !containsIgnoreCase(course.Title, search) && !containsIgnoreCase(course.Description, search) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if category != "" && !containsIgnoreCase(course.Category, category) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if difficulty != "" && !containsIgnoreCase(course.Difficulty, difficulty) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to frontend format
|
||||||
|
resources := courseResources[course.ID]
|
||||||
|
tags := make([]gin.H, len(course.Tags))
|
||||||
|
for i, tag := range course.Tags {
|
||||||
|
tags[i] = gin.H{
|
||||||
|
"name": tag,
|
||||||
|
"color": "#3b82f6", // Blue color for all tags
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
modules := make([]gin.H, len(resources))
|
||||||
|
for i, resource := range resources {
|
||||||
|
modules[i] = gin.H{
|
||||||
|
"id": fmt.Sprintf("module_%d", resource.ID),
|
||||||
|
"title": resource.Title,
|
||||||
|
"description": resource.Description,
|
||||||
|
"completed": false,
|
||||||
|
"resources": []gin.H{
|
||||||
|
{
|
||||||
|
"type": string(resource.Type),
|
||||||
|
"title": resource.Title,
|
||||||
|
"url": resource.URL,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
learningPath := gin.H{
|
||||||
|
"id": course.ID,
|
||||||
|
"title": course.Title,
|
||||||
|
"description": course.Description,
|
||||||
|
"category": course.Category,
|
||||||
|
"difficulty": course.Difficulty,
|
||||||
|
"duration": fmt.Sprintf("%d hours", course.Duration),
|
||||||
|
"thumbnail": course.Thumbnail,
|
||||||
|
"is_featured": course.ID <= 2, // First 2 courses are featured
|
||||||
|
"enrollment_count": rand.Intn(2000) + 200,
|
||||||
|
"rating": 4.0 + rand.Float64(),
|
||||||
|
"review_count": rand.Intn(200) + 20,
|
||||||
|
"creator": gin.H{
|
||||||
|
"username": "instructor",
|
||||||
|
"full_name": "Expert Instructor",
|
||||||
|
},
|
||||||
|
"tags": tags,
|
||||||
|
"modules": modules,
|
||||||
|
"createdAt": course.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||||
|
}
|
||||||
|
|
||||||
|
learningPaths = append(learningPaths, learningPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, learningPaths)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getLearningPathCategories(c *gin.Context) {
|
||||||
|
categories := []string{
|
||||||
|
"Web Development",
|
||||||
|
"Mobile Development",
|
||||||
|
"Programming",
|
||||||
|
"DevOps",
|
||||||
|
"Data Science",
|
||||||
|
"Design",
|
||||||
|
"Business",
|
||||||
|
"Cybersecurity",
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"categories": categories})
|
||||||
|
}
|
||||||
|
|
||||||
|
func enrollInLearningPath(c *gin.Context) {
|
||||||
|
pathID := parseInt(c.Param("id"))
|
||||||
|
userID := getUserIDFromToken(c)
|
||||||
|
|
||||||
|
if userID == 0 {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if course exists
|
||||||
|
course, exists := courses[pathID]
|
||||||
|
if !exists || !course.IsActive {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Learning path not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create or update progress
|
||||||
|
key := fmt.Sprintf("%d_%d", userID, pathID)
|
||||||
|
progress, exists := userProgress[key]
|
||||||
|
if !exists {
|
||||||
|
progress = UserProgress{
|
||||||
|
UserID: userID,
|
||||||
|
CourseID: pathID,
|
||||||
|
Status: "in_progress",
|
||||||
|
Progress: 0.0,
|
||||||
|
StartedAt: time.Now(),
|
||||||
|
LastAccessed: time.Now(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
progress.Status = "in_progress"
|
||||||
|
progress.LastAccessed = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
userProgress[key] = progress
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"message": "Successfully enrolled in learning path",
|
||||||
|
"enrolled": true,
|
||||||
|
"progress": progress,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func getLearningPath(c *gin.Context) {
|
||||||
|
pathID := parseInt(c.Param("id"))
|
||||||
|
|
||||||
|
course, exists := courses[pathID]
|
||||||
|
if !exists || !course.IsActive {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Learning path not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get resources
|
||||||
|
resources := courseResources[pathID]
|
||||||
|
course.Resources = resources
|
||||||
|
|
||||||
|
// Convert to frontend format
|
||||||
|
tags := make([]gin.H, len(course.Tags))
|
||||||
|
for i, tag := range course.Tags {
|
||||||
|
tags[i] = gin.H{
|
||||||
|
"name": tag,
|
||||||
|
"color": "#3b82f6",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
modules := make([]gin.H, len(resources))
|
||||||
|
for i, resource := range resources {
|
||||||
|
modules[i] = gin.H{
|
||||||
|
"id": fmt.Sprintf("module_%d", resource.ID),
|
||||||
|
"title": resource.Title,
|
||||||
|
"description": resource.Description,
|
||||||
|
"completed": false,
|
||||||
|
"resources": []gin.H{
|
||||||
|
{
|
||||||
|
"type": string(resource.Type),
|
||||||
|
"title": resource.Title,
|
||||||
|
"url": resource.URL,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
learningPath := gin.H{
|
||||||
|
"id": course.ID,
|
||||||
|
"title": course.Title,
|
||||||
|
"description": course.Description,
|
||||||
|
"category": course.Category,
|
||||||
|
"difficulty": course.Difficulty,
|
||||||
|
"duration": fmt.Sprintf("%d hours", course.Duration),
|
||||||
|
"thumbnail": course.Thumbnail,
|
||||||
|
"is_featured": course.ID <= 2,
|
||||||
|
"enrollment_count": rand.Intn(2000) + 200,
|
||||||
|
"rating": 4.0 + rand.Float64(),
|
||||||
|
"review_count": rand.Intn(200) + 20,
|
||||||
|
"creator": gin.H{
|
||||||
|
"username": "instructor",
|
||||||
|
"full_name": "Expert Instructor",
|
||||||
|
},
|
||||||
|
"tags": tags,
|
||||||
|
"modules": modules,
|
||||||
|
"createdAt": course.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, learningPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function for case-insensitive contains
|
||||||
|
func containsIgnoreCase(s, substr string) bool {
|
||||||
|
return strings.Contains(strings.ToLower(s), strings.ToLower(substr))
|
||||||
|
}
|
||||||
|
|
||||||
// User Progress Handlers
|
// User Progress Handlers
|
||||||
|
|
||||||
func getUserProgress(c *gin.Context) {
|
func getUserProgress(c *gin.Context) {
|
||||||
|
|||||||
@@ -21,6 +21,10 @@
|
|||||||
<a href="#contributing">Contributing</a>
|
<a href="#contributing">Contributing</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="./scorecard.png" alt="Code Quality Score" width="100%">
|
||||||
|
</p>
|
||||||
|
|
||||||
## Introduction
|
## Introduction
|
||||||
|
|
||||||
I built Trackeep because I was tired of juggling a dozen different apps for my digital life. You know how it is – bookmarks in one place, tasks in another, random notes scattered everywhere, and that great article you meant to read somewhere in your browser history.
|
I built Trackeep because I was tired of juggling a dozen different apps for my digital life. You know how it is – bookmarks in one place, tasks in another, random notes scattered everywhere, and that great article you meant to read somewhere in your browser history.
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ServerConfig struct {
|
||||||
|
Port string
|
||||||
|
ReadTimeout time.Duration
|
||||||
|
WriteTimeout time.Duration
|
||||||
|
IdleTimeout time.Duration
|
||||||
|
ShutdownTimeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Server ServerConfig
|
||||||
|
Database DatabaseConfig
|
||||||
|
App AppConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
type DatabaseConfig struct {
|
||||||
|
Host string
|
||||||
|
Port string
|
||||||
|
User string
|
||||||
|
Password string
|
||||||
|
Name string
|
||||||
|
SSLMode string
|
||||||
|
}
|
||||||
|
|
||||||
|
type AppConfig struct {
|
||||||
|
Version string
|
||||||
|
DemoMode bool
|
||||||
|
GinMode string
|
||||||
|
JWTSecret string
|
||||||
|
CorsOrigins string
|
||||||
|
}
|
||||||
|
|
||||||
|
func Load() *Config {
|
||||||
|
return &Config{
|
||||||
|
Server: ServerConfig{
|
||||||
|
Port: getEnvWithDefault("PORT", "8080"),
|
||||||
|
ReadTimeout: getDurationEnv("READ_TIMEOUT", 15*time.Second),
|
||||||
|
WriteTimeout: getDurationEnv("WRITE_TIMEOUT", 15*time.Second),
|
||||||
|
IdleTimeout: getDurationEnv("IDLE_TIMEOUT", 60*time.Second),
|
||||||
|
ShutdownTimeout: getDurationEnv("SHUTDOWN_TIMEOUT", 30*time.Second),
|
||||||
|
},
|
||||||
|
Database: DatabaseConfig{
|
||||||
|
Host: getEnvWithDefault("DB_HOST", "localhost"),
|
||||||
|
Port: getEnvWithDefault("DB_PORT", "5432"),
|
||||||
|
User: getEnvWithDefault("DB_USER", "trackeep"),
|
||||||
|
Password: os.Getenv("DB_PASSWORD"),
|
||||||
|
Name: getEnvWithDefault("DB_NAME", "trackeep"),
|
||||||
|
SSLMode: getEnvWithDefault("DB_SSL_MODE", "disable"),
|
||||||
|
},
|
||||||
|
App: AppConfig{
|
||||||
|
Version: getEnvWithDefault("APP_VERSION", "1.0.0"),
|
||||||
|
DemoMode: os.Getenv("VITE_DEMO_MODE") == "true",
|
||||||
|
GinMode: getEnvWithDefault("GIN_MODE", "debug"),
|
||||||
|
JWTSecret: os.Getenv("JWT_SECRET"),
|
||||||
|
CorsOrigins: getEnvWithDefault("CORS_ALLOWED_ORIGINS", ""),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) Validate() error {
|
||||||
|
if c.Database.Password == "" && !c.App.DemoMode {
|
||||||
|
return fmt.Errorf("DB_PASSWORD environment variable is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.App.GinMode == "release" && c.App.CorsOrigins == "" {
|
||||||
|
return fmt.Errorf("CORS_ALLOWED_ORIGINS must be set in production mode")
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.App.GinMode == "release" && c.App.JWTSecret == "" {
|
||||||
|
return fmt.Errorf("JWT_SECRET must be set in production mode")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) DSN() string {
|
||||||
|
return fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%s sslmode=%s",
|
||||||
|
c.Database.Host,
|
||||||
|
c.Database.User,
|
||||||
|
c.Database.Password,
|
||||||
|
c.Database.Name,
|
||||||
|
c.Database.Port,
|
||||||
|
c.Database.SSLMode,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getEnvWithDefault(key, defaultValue string) string {
|
||||||
|
if value := os.Getenv(key); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
|
||||||
|
func getDurationEnv(key string, defaultValue time.Duration) time.Duration {
|
||||||
|
value := os.Getenv(key)
|
||||||
|
if value == "" {
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
|
||||||
|
seconds, err := strconv.Atoi(value)
|
||||||
|
if err != nil {
|
||||||
|
duration, err := time.ParseDuration(value)
|
||||||
|
if err != nil {
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
return duration
|
||||||
|
}
|
||||||
|
|
||||||
|
return time.Duration(seconds) * time.Second
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func GetAPIConfig(c *gin.Context) {
|
||||||
|
scheme := "http"
|
||||||
|
if c.Request.TLS != nil {
|
||||||
|
scheme = "https"
|
||||||
|
}
|
||||||
|
|
||||||
|
host := c.Request.Host
|
||||||
|
if host == "" {
|
||||||
|
host = os.Getenv("HOST")
|
||||||
|
if host == "" {
|
||||||
|
host = "localhost:8080"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
apiURL := fmt.Sprintf("%s://%s/api/v1", scheme, host)
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"api_url": apiURL,
|
||||||
|
"demo_mode": os.Getenv("VITE_DEMO_MODE") == "true",
|
||||||
|
"version": "1.0.0",
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"runtime"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/trackeep/backend/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
var startTime = time.Now()
|
||||||
|
|
||||||
|
func HealthCheck(c *gin.Context) {
|
||||||
|
db := config.GetDB()
|
||||||
|
dbStatus := "connected"
|
||||||
|
var dbPingTime time.Duration = 0
|
||||||
|
|
||||||
|
if db == nil {
|
||||||
|
dbStatus = "disconnected"
|
||||||
|
} else {
|
||||||
|
sqlDB, err := db.DB()
|
||||||
|
if err != nil {
|
||||||
|
dbStatus = "error"
|
||||||
|
} else {
|
||||||
|
start := time.Now()
|
||||||
|
if err := sqlDB.Ping(); err != nil {
|
||||||
|
dbStatus = "error"
|
||||||
|
} else {
|
||||||
|
dbPingTime = time.Since(start)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionStatus := "ok"
|
||||||
|
|
||||||
|
var memStats runtime.MemStats
|
||||||
|
runtime.ReadMemStats(&memStats)
|
||||||
|
|
||||||
|
uptime := time.Since(startTime)
|
||||||
|
|
||||||
|
health := gin.H{
|
||||||
|
"status": "ok",
|
||||||
|
"message": "Trackeep API is running",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"uptime": uptime.String(),
|
||||||
|
"database": gin.H{
|
||||||
|
"status": dbStatus,
|
||||||
|
"ping_time": dbPingTime.String(),
|
||||||
|
},
|
||||||
|
"sessions": gin.H{
|
||||||
|
"status": sessionStatus,
|
||||||
|
},
|
||||||
|
"system": gin.H{
|
||||||
|
"goroutines": runtime.NumGoroutine(),
|
||||||
|
"memory": gin.H{
|
||||||
|
"alloc_mb": memStats.Alloc / 1024 / 1024,
|
||||||
|
"total_alloc_mb": memStats.TotalAlloc / 1024 / 1024,
|
||||||
|
"sys_mb": memStats.Sys / 1024 / 1024,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"timestamp": gin.H{
|
||||||
|
"human": time.Now().Format(time.RFC3339),
|
||||||
|
"unix": time.Now().Unix(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
overallStatus := "healthy"
|
||||||
|
if dbStatus != "connected" {
|
||||||
|
overallStatus = "degraded"
|
||||||
|
health["status"] = "degraded"
|
||||||
|
}
|
||||||
|
if sessionStatus != "ok" {
|
||||||
|
overallStatus = "degraded"
|
||||||
|
health["status"] = "degraded"
|
||||||
|
}
|
||||||
|
|
||||||
|
statusCode := 200
|
||||||
|
if overallStatus == "degraded" {
|
||||||
|
statusCode = 503
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(statusCode, health)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ReadinessCheck(c *gin.Context) {
|
||||||
|
db := config.GetDB()
|
||||||
|
if db == nil {
|
||||||
|
c.JSON(503, gin.H{"status": "not_ready", "reason": "database_not_connected"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlDB, err := db.DB()
|
||||||
|
if err != nil || sqlDB.Ping() != nil {
|
||||||
|
c.JSON(503, gin.H{"status": "not_ready", "reason": "database_ping_failed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(200, gin.H{"status": "ready"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func LivenessCheck(c *gin.Context) {
|
||||||
|
c.JSON(200, gin.H{"status": "alive", "timestamp": time.Now().Unix()})
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
@@ -25,9 +27,7 @@ type BraveSearchResponse struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type BraveNewsResponse struct {
|
type BraveNewsResponse struct {
|
||||||
News struct {
|
|
||||||
Results []map[string]interface{} `json:"results"`
|
Results []map[string]interface{} `json:"results"`
|
||||||
} `json:"news"`
|
|
||||||
Query struct {
|
Query struct {
|
||||||
Original string `json:"original"`
|
Original string `json:"original"`
|
||||||
Display string `json:"display"`
|
Display string `json:"display"`
|
||||||
@@ -47,6 +47,7 @@ type BraveSearchResult struct {
|
|||||||
|
|
||||||
// SearchWeb handles POST /api/v1/search/web
|
// SearchWeb handles POST /api/v1/search/web
|
||||||
func SearchWeb(c *gin.Context) {
|
func SearchWeb(c *gin.Context) {
|
||||||
|
fmt.Printf("DEBUG: SearchWeb function called\n")
|
||||||
var req struct {
|
var req struct {
|
||||||
Query string `json:"query" binding:"required"`
|
Query string `json:"query" binding:"required"`
|
||||||
Count int `json:"count"`
|
Count int `json:"count"`
|
||||||
@@ -135,6 +136,7 @@ func SearchWeb(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func SearchNews(c *gin.Context) {
|
func SearchNews(c *gin.Context) {
|
||||||
|
fmt.Printf("DEBUG: SearchNews function called\n")
|
||||||
var req struct {
|
var req struct {
|
||||||
Query string `json:"query" binding:"required"`
|
Query string `json:"query" binding:"required"`
|
||||||
Count int `json:"count"`
|
Count int `json:"count"`
|
||||||
@@ -174,20 +176,35 @@ func SearchNews(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadGateway, gin.H{"error": "Failed to contact Brave News API"})
|
c.JSON(http.StatusBadGateway, gin.H{"error": "Failed to contact Brave News API"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
resp.Body.Close()
|
||||||
c.JSON(http.StatusBadGateway, gin.H{"error": fmt.Sprintf("Brave News API error: %d", resp.StatusCode)})
|
c.JSON(http.StatusBadGateway, gin.H{"error": fmt.Sprintf("Brave News API error: %d", resp.StatusCode)})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Read the response body for debugging
|
||||||
|
bodyBytes, err := io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read response body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("DEBUG: Raw Brave News API response: %s\n", string(bodyBytes))
|
||||||
|
|
||||||
var braveResp BraveNewsResponse
|
var braveResp BraveNewsResponse
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&braveResp); err != nil {
|
if err := json.NewDecoder(bytes.NewReader(bodyBytes)).Decode(&braveResp); err != nil {
|
||||||
|
fmt.Printf("DEBUG: JSON decode error: %v\n", err)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to decode Brave news response"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to decode Brave news response"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
resultsRaw := braveResp.News.Results
|
// Debug logging
|
||||||
|
fmt.Printf("DEBUG: Parsed BraveNewsResponse: %+v\n", braveResp)
|
||||||
|
fmt.Printf("DEBUG: Number of results: %d\n", len(braveResp.Results))
|
||||||
|
|
||||||
|
resultsRaw := braveResp.Results
|
||||||
results := make([]BraveSearchResult, 0, len(resultsRaw))
|
results := make([]BraveSearchResult, 0, len(resultsRaw))
|
||||||
for _, r := range resultsRaw {
|
for _, r := range resultsRaw {
|
||||||
title, _ := r["title"].(string)
|
title, _ := r["title"].(string)
|
||||||
|
|||||||
+595
-45
@@ -1,6 +1,10 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
@@ -9,10 +13,13 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
// UpdateInfo represents information about an available update
|
// UpdateInfo represents information about an available update
|
||||||
@@ -22,6 +29,9 @@ type UpdateInfo struct {
|
|||||||
DownloadURL string `json:"downloadUrl"`
|
DownloadURL string `json:"downloadUrl"`
|
||||||
Mandatory bool `json:"mandatory"`
|
Mandatory bool `json:"mandatory"`
|
||||||
Size string `json:"size"`
|
Size string `json:"size"`
|
||||||
|
Checksum string `json:"checksum"`
|
||||||
|
PublishedAt string `json:"publishedAt"`
|
||||||
|
Prerelease bool `json:"prerelease"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateStatus represents the current status of an update
|
// UpdateStatus represents the current status of an update
|
||||||
@@ -44,6 +54,7 @@ var (
|
|||||||
updateMutex sync.RWMutex
|
updateMutex sync.RWMutex
|
||||||
currentUpdate *UpdateInfo
|
currentUpdate *UpdateInfo
|
||||||
updateProgress *UpdateStatus
|
updateProgress *UpdateStatus
|
||||||
|
backupPath string // Store backup path for rollback
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@@ -68,18 +79,32 @@ func CheckForUpdates(c *gin.Context) {
|
|||||||
currentVersion = "1.0.0"
|
currentVersion = "1.0.0"
|
||||||
}
|
}
|
||||||
|
|
||||||
// In a real implementation, this would check against a remote update server
|
// Get GitHub token from OAuth service (required)
|
||||||
// For demo purposes, we'll simulate checking for updates
|
githubToken := getGitHubTokenFromContext(c)
|
||||||
latestVersion, updateAvailable := simulateUpdateCheck(currentVersion)
|
if githubToken == "" {
|
||||||
|
log.Printf("No GitHub token from OAuth service - update check failed")
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||||
|
"error": "OAuth service not available",
|
||||||
|
"message": "Please ensure OAuth service is running and you are authenticated",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Using GitHub token from OAuth service for update check")
|
||||||
|
|
||||||
|
// Check for updates using GitHub API
|
||||||
|
updateInfo, updateAvailable, err := checkForUpdatesWithGitHub(currentVersion, githubToken)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to check for updates: %v", err)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
|
"error": "Failed to check for updates",
|
||||||
|
"details": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if updateAvailable {
|
if updateAvailable {
|
||||||
currentUpdate = &UpdateInfo{
|
currentUpdate = updateInfo
|
||||||
Version: latestVersion,
|
|
||||||
ReleaseNotes: "• New AI features added\n• Performance improvements\n• Bug fixes and security patches\n• Enhanced user interface",
|
|
||||||
DownloadURL: "https://github.com/trackeep/trackeep/releases/latest",
|
|
||||||
Mandatory: false,
|
|
||||||
Size: "~25MB",
|
|
||||||
}
|
|
||||||
updateProgress.Available = true
|
updateProgress.Available = true
|
||||||
} else {
|
} else {
|
||||||
currentUpdate = nil
|
currentUpdate = nil
|
||||||
@@ -89,7 +114,7 @@ func CheckForUpdates(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"updateAvailable": updateAvailable,
|
"updateAvailable": updateAvailable,
|
||||||
"currentVersion": currentVersion,
|
"currentVersion": currentVersion,
|
||||||
"latestVersion": latestVersion,
|
"latestVersion": updateInfo.Version,
|
||||||
"updateInfo": currentUpdate,
|
"updateInfo": currentUpdate,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -140,17 +165,275 @@ func UpdateProgressWebSocket(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// simulateUpdateCheck simulates checking for updates
|
// checkForUpdatesWithGitHub checks for updates using GitHub API
|
||||||
func simulateUpdateCheck(currentVersion string) (string, bool) {
|
func checkForUpdatesWithGitHub(currentVersion, githubToken string) (*UpdateInfo, bool, error) {
|
||||||
// Simulate version check - in reality this would call an update API
|
// GitHub repository information
|
||||||
versions := []string{"1.0.1", "1.1.0", "1.2.0"}
|
owner := "Dvorinka"
|
||||||
|
repo := "Trackeep"
|
||||||
|
|
||||||
// For demo, always return a newer version
|
// Log which token source we're using
|
||||||
if len(versions) > 0 {
|
if githubToken != "" {
|
||||||
return versions[0], true
|
log.Printf("Using GitHub token from OAuth service")
|
||||||
|
} else {
|
||||||
|
log.Printf("No GitHub token available - OAuth service should be running")
|
||||||
|
return nil, false, fmt.Errorf("OAuth service not available - please ensure OAuth service is running")
|
||||||
}
|
}
|
||||||
|
|
||||||
return currentVersion, false
|
// Create HTTP request to GitHub API
|
||||||
|
url := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", owner, repo)
|
||||||
|
req, err := http.NewRequest("GET", url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add authorization header if token is available
|
||||||
|
if githubToken != "" {
|
||||||
|
req.Header.Set("Authorization", "token "+githubToken)
|
||||||
|
}
|
||||||
|
req.Header.Set("Accept", "application/vnd.github.v3+json")
|
||||||
|
|
||||||
|
// Make the request
|
||||||
|
client := &http.Client{Timeout: 10 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, fmt.Errorf("failed to fetch releases: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, false, fmt.Errorf("GitHub API returned status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the release response
|
||||||
|
var release struct {
|
||||||
|
TagName string `json:"tag_name"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
PublishedAt string `json:"published_at"`
|
||||||
|
Prerelease bool `json:"prerelease"`
|
||||||
|
Assets []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
BrowserDownloadURL string `json:"browser_download_url"`
|
||||||
|
} `json:"assets"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
|
||||||
|
return nil, false, fmt.Errorf("failed to parse release response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare versions (simple semantic version comparison)
|
||||||
|
if !isNewerVersion(release.TagName, currentVersion) {
|
||||||
|
return nil, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the appropriate asset for the current platform
|
||||||
|
var downloadURL, size, checksum string
|
||||||
|
for _, asset := range release.Assets {
|
||||||
|
// Look for platform-specific binaries
|
||||||
|
if isPlatformAsset(asset.Name) {
|
||||||
|
downloadURL = asset.BrowserDownloadURL
|
||||||
|
size = formatBytes(asset.Size)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no platform-specific asset found, use the first one
|
||||||
|
if downloadURL == "" && len(release.Assets) > 0 {
|
||||||
|
downloadURL = release.Assets[0].BrowserDownloadURL
|
||||||
|
size = formatBytes(release.Assets[0].Size)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to get checksum from release notes or assets
|
||||||
|
checksum = extractChecksum(release.Body)
|
||||||
|
|
||||||
|
updateInfo := &UpdateInfo{
|
||||||
|
Version: release.TagName,
|
||||||
|
ReleaseNotes: release.Body,
|
||||||
|
DownloadURL: downloadURL,
|
||||||
|
Mandatory: false, // Could be determined from release notes or tags
|
||||||
|
Size: size,
|
||||||
|
Checksum: checksum,
|
||||||
|
PublishedAt: release.PublishedAt,
|
||||||
|
Prerelease: release.Prerelease,
|
||||||
|
}
|
||||||
|
|
||||||
|
return updateInfo, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getGitHubTokenFromContext extracts GitHub token from request context
|
||||||
|
func getGitHubTokenFromContext(c *gin.Context) string {
|
||||||
|
// Extract Authorization header
|
||||||
|
authHeader := c.GetHeader("Authorization")
|
||||||
|
if authHeader == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove "Bearer " prefix
|
||||||
|
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
|
||||||
|
if tokenString == authHeader {
|
||||||
|
// No Bearer prefix found
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse JWT token
|
||||||
|
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||||
|
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||||
|
}
|
||||||
|
return []byte(os.Getenv("JWT_SECRET")), nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil || !token.Valid {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract claims
|
||||||
|
claims, ok := token.Claims.(jwt.MapClaims)
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get GitHub access token from claims
|
||||||
|
githubToken, ok := claims["access_token"]
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if token is still valid
|
||||||
|
expiresAt, ok := claims["expires_at"]
|
||||||
|
if ok {
|
||||||
|
if expTime, ok := expiresAt.(float64); ok {
|
||||||
|
if time.Now().Unix() > int64(expTime) {
|
||||||
|
return "" // Token expired
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return githubToken.(string)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper functions for GitHub update functionality
|
||||||
|
|
||||||
|
// getGitHubTokenFromOAuth attempts to get GitHub token from OAuth service
|
||||||
|
func getGitHubTokenFromOAuth() string {
|
||||||
|
// Try to get token from current user session
|
||||||
|
// This would typically be extracted from the JWT token in the request context
|
||||||
|
// For now, we'll implement a basic version that checks for a logged-in user
|
||||||
|
|
||||||
|
// In a real implementation, this would:
|
||||||
|
// 1. Extract the JWT from the current request context
|
||||||
|
// 2. Parse the JWT to get the GitHub access token
|
||||||
|
// 3. Return the token if valid
|
||||||
|
|
||||||
|
// For now, return empty string to indicate no OAuth token available
|
||||||
|
// This will be implemented when we have proper session management
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// isNewerVersion compares semantic versions
|
||||||
|
func isNewerVersion(latest, current string) bool {
|
||||||
|
// Remove 'v' prefix if present
|
||||||
|
latest = strings.TrimPrefix(latest, "v")
|
||||||
|
current = strings.TrimPrefix(current, "v")
|
||||||
|
|
||||||
|
latestParts := strings.Split(latest, ".")
|
||||||
|
currentParts := strings.Split(current, ".")
|
||||||
|
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
var latestNum, currentNum int
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if i < len(latestParts) {
|
||||||
|
latestNum, err = strconv.Atoi(latestParts[i])
|
||||||
|
if err != nil {
|
||||||
|
latestNum = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if i < len(currentParts) {
|
||||||
|
currentNum, err = strconv.Atoi(currentParts[i])
|
||||||
|
if err != nil {
|
||||||
|
currentNum = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if latestNum > currentNum {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if latestNum < currentNum {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// isPlatformAsset checks if an asset is appropriate for the current platform
|
||||||
|
func isPlatformAsset(filename string) bool {
|
||||||
|
arch := runtime.GOARCH
|
||||||
|
os := runtime.GOOS
|
||||||
|
|
||||||
|
filename = strings.ToLower(filename)
|
||||||
|
|
||||||
|
// Check for platform-specific patterns
|
||||||
|
switch os {
|
||||||
|
case "windows":
|
||||||
|
return strings.Contains(filename, "windows") || strings.Contains(filename, "win") || strings.HasSuffix(filename, ".exe")
|
||||||
|
case "linux":
|
||||||
|
return strings.Contains(filename, "linux") || strings.Contains(filename, "ubuntu") || strings.Contains(filename, "debian")
|
||||||
|
case "darwin":
|
||||||
|
return strings.Contains(filename, "darwin") || strings.Contains(filename, "macos") || strings.Contains(filename, "mac")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check architecture
|
||||||
|
if arch == "amd64" {
|
||||||
|
return strings.Contains(filename, "amd64") || strings.Contains(filename, "x86_64")
|
||||||
|
}
|
||||||
|
if arch == "arm64" {
|
||||||
|
return strings.Contains(filename, "arm64") || strings.Contains(filename, "aarch64")
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatBytes formats bytes into human readable format
|
||||||
|
func formatBytes(bytes int64) string {
|
||||||
|
const unit = 1024
|
||||||
|
if bytes < unit {
|
||||||
|
return fmt.Sprintf("%d B", bytes)
|
||||||
|
}
|
||||||
|
div, exp := int64(unit), 0
|
||||||
|
for n := bytes / unit; n >= unit; n /= unit {
|
||||||
|
div *= unit
|
||||||
|
exp++
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractChecksum attempts to extract SHA256 checksum from release notes
|
||||||
|
func extractChecksum(body string) string {
|
||||||
|
lines := strings.Split(body, "\n")
|
||||||
|
for _, line := range lines {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if strings.HasPrefix(line, "SHA256:") || strings.HasPrefix(line, "Checksum:") {
|
||||||
|
parts := strings.Fields(line)
|
||||||
|
if len(parts) >= 2 {
|
||||||
|
return parts[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Also look for pattern like "checksum: sha256:..."
|
||||||
|
if strings.Contains(line, "sha256:") {
|
||||||
|
idx := strings.Index(line, "sha256:")
|
||||||
|
if idx != -1 {
|
||||||
|
checksum := strings.TrimSpace(line[idx+7:])
|
||||||
|
if len(checksum) == 64 { // SHA256 length
|
||||||
|
return checksum
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// performUpdate performs the actual update process
|
// performUpdate performs the actual update process
|
||||||
@@ -161,18 +444,31 @@ func performUpdate(updateInfo *UpdateInfo) {
|
|||||||
updateProgress.Error = ""
|
updateProgress.Error = ""
|
||||||
updateMutex.Unlock()
|
updateMutex.Unlock()
|
||||||
|
|
||||||
// Broadcast progress update
|
log.Printf("Starting update to version %s", updateInfo.Version)
|
||||||
log.Printf("Update progress: %.1f%% downloading", updateProgress.Progress)
|
|
||||||
|
|
||||||
// Simulate download
|
|
||||||
for i := 0; i <= 100; i += 10 {
|
|
||||||
time.Sleep(500 * time.Millisecond)
|
|
||||||
|
|
||||||
|
// Download the update
|
||||||
|
tempFile, err := downloadUpdate(updateInfo)
|
||||||
|
if err != nil {
|
||||||
updateMutex.Lock()
|
updateMutex.Lock()
|
||||||
updateProgress.Progress = float64(i)
|
updateProgress.Downloading = false
|
||||||
|
updateProgress.Error = fmt.Sprintf("Failed to download update: %v", err)
|
||||||
updateMutex.Unlock()
|
updateMutex.Unlock()
|
||||||
|
log.Printf("Update download failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer os.Remove(tempFile)
|
||||||
|
|
||||||
log.Printf("Update progress: %.1f%% downloading", updateProgress.Progress)
|
// Verify checksum if available
|
||||||
|
if updateInfo.Checksum != "" {
|
||||||
|
if err := verifyChecksum(tempFile, updateInfo.Checksum); err != nil {
|
||||||
|
updateMutex.Lock()
|
||||||
|
updateProgress.Downloading = false
|
||||||
|
updateProgress.Error = fmt.Sprintf("Checksum verification failed: %v", err)
|
||||||
|
updateMutex.Unlock()
|
||||||
|
log.Printf("Checksum verification failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("Checksum verification passed")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start installation
|
// Start installation
|
||||||
@@ -182,36 +478,30 @@ func performUpdate(updateInfo *UpdateInfo) {
|
|||||||
updateProgress.Progress = 0
|
updateProgress.Progress = 0
|
||||||
updateMutex.Unlock()
|
updateMutex.Unlock()
|
||||||
|
|
||||||
log.Printf("Update progress: %.1f%% downloading", updateProgress.Progress)
|
|
||||||
|
|
||||||
// Backup user data
|
// Backup user data
|
||||||
if err := backupUserData(); err != nil {
|
if err := backupUserData(); err != nil {
|
||||||
updateMutex.Lock()
|
updateMutex.Lock()
|
||||||
updateProgress.Installing = false
|
updateProgress.Installing = false
|
||||||
updateProgress.Error = fmt.Sprintf("Failed to backup user data: %v", err)
|
updateProgress.Error = fmt.Sprintf("Failed to backup user data: %v", err)
|
||||||
updateMutex.Unlock()
|
updateMutex.Unlock()
|
||||||
log.Printf("Update progress: %.1f%% downloading", updateProgress.Progress)
|
log.Printf("Backup failed: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Simulate installation
|
// Extract and install the update
|
||||||
for i := 0; i <= 100; i += 20 {
|
if err := extractAndInstall(tempFile, updateInfo); err != nil {
|
||||||
time.Sleep(1 * time.Second)
|
// Attempt rollback on failure
|
||||||
|
log.Printf("Installation failed, attempting rollback: %v", err)
|
||||||
updateMutex.Lock()
|
if rollbackErr := rollbackUpdate(); rollbackErr != nil {
|
||||||
updateProgress.Progress = float64(i)
|
log.Printf("Rollback also failed: %v", rollbackErr)
|
||||||
updateMutex.Unlock()
|
} else {
|
||||||
|
log.Printf("Rollback completed successfully")
|
||||||
log.Printf("Update progress: %.1f%% downloading", updateProgress.Progress)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Perform the actual update
|
|
||||||
if err := applyUpdate(updateInfo); err != nil {
|
|
||||||
updateMutex.Lock()
|
updateMutex.Lock()
|
||||||
updateProgress.Installing = false
|
updateProgress.Installing = false
|
||||||
updateProgress.Error = fmt.Sprintf("Failed to apply update: %v", err)
|
updateProgress.Error = fmt.Sprintf("Failed to install update: %v", err)
|
||||||
updateMutex.Unlock()
|
updateMutex.Unlock()
|
||||||
log.Printf("Update progress: %.1f%% downloading", updateProgress.Progress)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,22 +512,215 @@ func performUpdate(updateInfo *UpdateInfo) {
|
|||||||
updateProgress.Progress = 100
|
updateProgress.Progress = 100
|
||||||
updateMutex.Unlock()
|
updateMutex.Unlock()
|
||||||
|
|
||||||
log.Printf("Update progress: %.1f%% downloading", updateProgress.Progress)
|
log.Printf("Update to version %s completed successfully", updateInfo.Version)
|
||||||
|
|
||||||
// Trigger application restart after a delay
|
// Trigger application restart after a delay
|
||||||
time.Sleep(2 * time.Second)
|
time.Sleep(2 * time.Second)
|
||||||
restartApplication()
|
restartApplication()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// downloadUpdate downloads the update file with progress tracking
|
||||||
|
func downloadUpdate(updateInfo *UpdateInfo) (string, error) {
|
||||||
|
if updateInfo.DownloadURL == "" {
|
||||||
|
return "", fmt.Errorf("no download URL available")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create temporary file
|
||||||
|
tempFile, err := os.CreateTemp("", "trackeep-update-*.zip")
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create temp file: %w", err)
|
||||||
|
}
|
||||||
|
defer tempFile.Close()
|
||||||
|
|
||||||
|
// Make HTTP request
|
||||||
|
req, err := http.NewRequest("GET", updateInfo.DownloadURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &http.Client{}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to download: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", fmt.Errorf("download failed with status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get content length for progress tracking
|
||||||
|
contentLength := resp.ContentLength
|
||||||
|
var downloaded int64
|
||||||
|
|
||||||
|
// Create progress reporter
|
||||||
|
progress := make(chan int64)
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
bytes, ok := <-progress
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
downloaded += bytes
|
||||||
|
if contentLength > 0 {
|
||||||
|
percent := float64(downloaded) / float64(contentLength) * 100
|
||||||
|
updateMutex.Lock()
|
||||||
|
updateProgress.Progress = percent
|
||||||
|
updateMutex.Unlock()
|
||||||
|
log.Printf("Download progress: %.1f%%", percent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Download with progress tracking
|
||||||
|
writer := &progressWriter{writer: tempFile, progress: progress}
|
||||||
|
_, err = io.Copy(writer, resp.Body)
|
||||||
|
close(progress)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to save download: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return tempFile.Name(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// progressWriter tracks download progress
|
||||||
|
type progressWriter struct {
|
||||||
|
writer io.Writer
|
||||||
|
progress chan<- int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pw *progressWriter) Write(p []byte) (int, error) {
|
||||||
|
n, err := pw.writer.Write(p)
|
||||||
|
if err == nil && n > 0 {
|
||||||
|
pw.progress <- int64(n)
|
||||||
|
}
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// verifyChecksum verifies the SHA256 checksum of a file
|
||||||
|
func verifyChecksum(filePath, expectedChecksum string) error {
|
||||||
|
file, err := os.Open(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to open file: %w", err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
hasher := sha256.New()
|
||||||
|
if _, err := io.Copy(hasher, file); err != nil {
|
||||||
|
return fmt.Errorf("failed to calculate checksum: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
actualChecksum := hex.EncodeToString(hasher.Sum(nil))
|
||||||
|
if !strings.EqualFold(actualChecksum, expectedChecksum) {
|
||||||
|
return fmt.Errorf("checksum mismatch: expected %s, got %s", expectedChecksum, actualChecksum)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractAndInstall extracts the update and installs it
|
||||||
|
func extractAndInstall(filePath string, updateInfo *UpdateInfo) error {
|
||||||
|
// Get current executable path
|
||||||
|
executable, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get executable path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get directory of executable
|
||||||
|
installDir := filepath.Dir(executable)
|
||||||
|
|
||||||
|
// Open the zip file
|
||||||
|
reader, err := zip.OpenReader(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to open zip file: %w", err)
|
||||||
|
}
|
||||||
|
defer reader.Close()
|
||||||
|
|
||||||
|
// Extract files
|
||||||
|
totalFiles := len(reader.File)
|
||||||
|
extractedFiles := 0
|
||||||
|
|
||||||
|
for _, file := range reader.File {
|
||||||
|
// Update progress
|
||||||
|
progress := float64(extractedFiles) / float64(totalFiles) * 100
|
||||||
|
updateMutex.Lock()
|
||||||
|
updateProgress.Progress = progress
|
||||||
|
updateMutex.Unlock()
|
||||||
|
|
||||||
|
// Skip directories for now
|
||||||
|
if file.FileInfo().IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create file path
|
||||||
|
filePath := filepath.Join(installDir, file.Name)
|
||||||
|
|
||||||
|
// Ensure directory exists
|
||||||
|
if err := os.MkdirAll(filepath.Dir(filePath), 0755); err != nil {
|
||||||
|
return fmt.Errorf("failed to create directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract file
|
||||||
|
if err := extractFile(file, filePath); err != nil {
|
||||||
|
return fmt.Errorf("failed to extract %s: %w", file.Name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
extractedFiles++
|
||||||
|
log.Printf("Extracted: %s", file.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update version in environment
|
||||||
|
os.Setenv("APP_VERSION", updateInfo.Version)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractFile extracts a single file from zip
|
||||||
|
func extractFile(file *zip.File, destination string) error {
|
||||||
|
// Open file in zip
|
||||||
|
rc, err := file.Open()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer rc.Close()
|
||||||
|
|
||||||
|
// Create destination file
|
||||||
|
destFile, err := os.OpenFile(destination, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, file.FileInfo().Mode())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer destFile.Close()
|
||||||
|
|
||||||
|
// Copy file contents
|
||||||
|
_, err = io.Copy(destFile, rc)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// backupUserData creates a backup of user data
|
// backupUserData creates a backup of user data
|
||||||
func backupUserData() error {
|
func backupUserData() error {
|
||||||
backupDir := filepath.Join(os.TempDir(), "trackeep_backup", time.Now().Format("20060102_150405"))
|
backupDir := filepath.Join(os.TempDir(), "trackeep_backup", time.Now().Format("20060102_150405"))
|
||||||
|
|
||||||
|
// Store backup path globally for potential rollback
|
||||||
|
backupPath = backupDir
|
||||||
|
|
||||||
// Create backup directory
|
// Create backup directory
|
||||||
if err := os.MkdirAll(backupDir, 0755); err != nil {
|
if err := os.MkdirAll(backupDir, 0755); err != nil {
|
||||||
return fmt.Errorf("failed to create backup directory: %w", err)
|
return fmt.Errorf("failed to create backup directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get current executable path
|
||||||
|
executable, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get executable path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backup current executable
|
||||||
|
backupExecPath := filepath.Join(backupDir, filepath.Base(executable))
|
||||||
|
if err := copyFile(executable, backupExecPath); err != nil {
|
||||||
|
return fmt.Errorf("failed to backup executable: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Backup database
|
// Backup database
|
||||||
dbPath := os.Getenv("DB_PATH")
|
dbPath := os.Getenv("DB_PATH")
|
||||||
if dbPath == "" {
|
if dbPath == "" {
|
||||||
@@ -354,7 +837,74 @@ func getUpdateStatusString(status UpdateStatus) string {
|
|||||||
return "Not Available"
|
return "Not Available"
|
||||||
}
|
}
|
||||||
|
|
||||||
// copyFile copies a file from src to dst
|
// rollbackUpdate restores the application from backup
|
||||||
|
func rollbackUpdate() error {
|
||||||
|
if backupPath == "" {
|
||||||
|
return fmt.Errorf("no backup path available for rollback")
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Starting rollback from backup: %s", backupPath)
|
||||||
|
|
||||||
|
// Get current executable path
|
||||||
|
executable, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get executable path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore executable
|
||||||
|
backupExecPath := filepath.Join(backupPath, filepath.Base(executable))
|
||||||
|
if _, err := os.Stat(backupExecPath); err == nil {
|
||||||
|
if err := copyFile(backupExecPath, executable); err != nil {
|
||||||
|
return fmt.Errorf("failed to restore executable: %w", err)
|
||||||
|
}
|
||||||
|
log.Printf("Restored executable from backup")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore database
|
||||||
|
backupDBPath := filepath.Join(backupPath, "trackeep.db")
|
||||||
|
dbPath := os.Getenv("DB_PATH")
|
||||||
|
if dbPath == "" {
|
||||||
|
dbPath = "./trackeep.db"
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(backupDBPath); err == nil {
|
||||||
|
if err := copyFile(backupDBPath, dbPath); err != nil {
|
||||||
|
return fmt.Errorf("failed to restore database: %w", err)
|
||||||
|
}
|
||||||
|
log.Printf("Restored database from backup")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore uploads directory
|
||||||
|
backupUploadsDir := filepath.Join(backupPath, "uploads")
|
||||||
|
uploadsDir := "./uploads"
|
||||||
|
|
||||||
|
if _, err := os.Stat(backupUploadsDir); err == nil {
|
||||||
|
// Remove current uploads directory and restore from backup
|
||||||
|
if err := os.RemoveAll(uploadsDir); err != nil {
|
||||||
|
log.Printf("Warning: failed to remove uploads directory: %v", err)
|
||||||
|
}
|
||||||
|
if err := copyDirectory(backupUploadsDir, uploadsDir); err != nil {
|
||||||
|
return fmt.Errorf("failed to restore uploads: %w", err)
|
||||||
|
}
|
||||||
|
log.Printf("Restored uploads from backup")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore configuration files
|
||||||
|
configFiles := []string{".env", "docker-compose.yml"}
|
||||||
|
for _, file := range configFiles {
|
||||||
|
backupFile := filepath.Join(backupPath, file)
|
||||||
|
if _, err := os.Stat(backupFile); err == nil {
|
||||||
|
if err := copyFile(backupFile, file); err != nil {
|
||||||
|
log.Printf("Warning: failed to restore %s: %v", file, err)
|
||||||
|
} else {
|
||||||
|
log.Printf("Restored %s from backup", file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Rollback completed successfully")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
func copyFile(src, dst string) error {
|
func copyFile(src, dst string) error {
|
||||||
sourceFile, err := os.Open(src)
|
sourceFile, err := os.Open(src)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -171,17 +171,13 @@ func GetYouTubeTrending(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, response)
|
c.JSON(http.StatusOK, response)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetPredefinedChannelVideos handles GET /api/v1/youtube/predefined-channels
|
|
||||||
func GetPredefinedChannelVideos(c *gin.Context) {
|
func GetPredefinedChannelVideos(c *gin.Context) {
|
||||||
// Get query parameters
|
|
||||||
maxResults, _ := strconv.Atoi(c.DefaultQuery("max_results", "5"))
|
maxResults, _ := strconv.Atoi(c.DefaultQuery("max_results", "5"))
|
||||||
|
|
||||||
// Validate max results
|
|
||||||
if maxResults < 1 || maxResults > 20 {
|
if maxResults < 1 || maxResults > 20 {
|
||||||
maxResults = 10
|
maxResults = 10
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get videos from predefined channels
|
|
||||||
response, err := services.GetPredefinedChannelVideos(maxResults)
|
response, err := services.GetPredefinedChannelVideos(maxResults)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
@@ -193,3 +189,32 @@ func GetPredefinedChannelVideos(c *gin.Context) {
|
|||||||
|
|
||||||
c.JSON(http.StatusOK, response)
|
c.JSON(http.StatusOK, response)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func YouTubeSearchTest(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
Query string `json:"query" binding:"required"`
|
||||||
|
MaxResults int `json:"max_results"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.MaxResults <= 0 {
|
||||||
|
req.MaxResults = 5
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := services.SearchYouTubeVideos(req.Query, req.MaxResults, "")
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
|
"error": "Failed to search YouTube videos",
|
||||||
|
"details": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"data": response,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
+20
-248
@@ -2,15 +2,11 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"runtime"
|
|
||||||
"strings"
|
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/joho/godotenv"
|
"github.com/joho/godotenv"
|
||||||
@@ -18,29 +14,24 @@ import (
|
|||||||
"github.com/trackeep/backend/handlers"
|
"github.com/trackeep/backend/handlers"
|
||||||
"github.com/trackeep/backend/middleware"
|
"github.com/trackeep/backend/middleware"
|
||||||
"github.com/trackeep/backend/models"
|
"github.com/trackeep/backend/models"
|
||||||
"github.com/trackeep/backend/services"
|
|
||||||
"github.com/trackeep/backend/utils"
|
"github.com/trackeep/backend/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
// IsDemoMode checks if demo mode is enabled
|
|
||||||
func IsDemoMode() bool {
|
func IsDemoMode() bool {
|
||||||
return os.Getenv("VITE_DEMO_MODE") == "true"
|
return os.Getenv("VITE_DEMO_MODE") == "true"
|
||||||
}
|
}
|
||||||
|
|
||||||
// initializeSecuritySecrets sets up JWT and encryption secrets on startup
|
|
||||||
func initializeSecuritySecrets() error {
|
func initializeSecuritySecrets() error {
|
||||||
// Initialize JWT secret
|
|
||||||
jwtSecret, err := utils.GetOrCreateJWTSecret()
|
jwtSecret, err := utils.GetOrCreateJWTSecret()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to initialize JWT secret: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
os.Setenv("JWT_SECRET", jwtSecret)
|
os.Setenv("JWT_SECRET", jwtSecret)
|
||||||
log.Println("JWT secret initialized successfully")
|
log.Println("JWT secret initialized successfully")
|
||||||
|
|
||||||
// Initialize encryption key
|
|
||||||
encryptionKey, err := utils.GetOrCreateEncryptionKey()
|
encryptionKey, err := utils.GetOrCreateEncryptionKey()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to initialize encryption key: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
os.Setenv("ENCRYPTION_KEY", encryptionKey)
|
os.Setenv("ENCRYPTION_KEY", encryptionKey)
|
||||||
log.Println("Encryption key initialized successfully")
|
log.Println("Encryption key initialized successfully")
|
||||||
@@ -48,13 +39,9 @@ func initializeSecuritySecrets() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var startTime = time.Now()
|
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
// Set application version
|
|
||||||
os.Setenv("APP_VERSION", "1.0.0")
|
os.Setenv("APP_VERSION", "1.0.0")
|
||||||
|
|
||||||
// Load environment variables from multiple possible locations
|
|
||||||
envPaths := []string{".env", "../.env", "/app/.env"}
|
envPaths := []string{".env", "../.env", "/app/.env"}
|
||||||
envLoaded := false
|
envLoaded := false
|
||||||
|
|
||||||
@@ -70,8 +57,12 @@ func main() {
|
|||||||
log.Println("No .env file found, using environment variables only")
|
log.Println("No .env file found, using environment variables only")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize database (skip in demo mode)
|
cfg := config.Load()
|
||||||
if !IsDemoMode() {
|
if err := cfg.Validate(); err != nil {
|
||||||
|
log.Printf("Configuration warning: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !cfg.App.DemoMode {
|
||||||
config.InitDatabase()
|
config.InitDatabase()
|
||||||
models.InitDB()
|
models.InitDB()
|
||||||
models.AutoMigrate()
|
models.AutoMigrate()
|
||||||
@@ -115,191 +106,12 @@ func main() {
|
|||||||
// Apply general rate limiting to all endpoints
|
// Apply general rate limiting to all endpoints
|
||||||
r.Use(middleware.GeneralRateLimit(rateLimiters["general"]))
|
r.Use(middleware.GeneralRateLimit(rateLimiters["general"]))
|
||||||
|
|
||||||
// CORS middleware - environment-aware
|
r.Use(middleware.CORSMiddleware())
|
||||||
r.Use(func(c *gin.Context) {
|
|
||||||
allowedOrigins := os.Getenv("CORS_ALLOWED_ORIGINS")
|
|
||||||
ginMode := os.Getenv("GIN_MODE")
|
|
||||||
|
|
||||||
// Default origins based on environment
|
r.GET("/health", handlers.HealthCheck)
|
||||||
if allowedOrigins == "" {
|
r.GET("/ready", handlers.ReadinessCheck)
|
||||||
if ginMode == "release" {
|
r.GET("/live", handlers.LivenessCheck)
|
||||||
// Production: no default origins - must be explicitly set
|
r.GET("/api/v1/config", handlers.GetAPIConfig)
|
||||||
c.JSON(http.StatusForbidden, gin.H{
|
|
||||||
"error": "CORS not configured for production",
|
|
||||||
"message": "Please set CORS_ALLOWED_ORIGINS environment variable",
|
|
||||||
})
|
|
||||||
c.Abort()
|
|
||||||
return
|
|
||||||
} else {
|
|
||||||
// Development: allow localhost origins
|
|
||||||
allowedOrigins = "http://localhost:5173,http://localhost:3000,http://localhost:8080"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
origin := c.Request.Header.Get("Origin")
|
|
||||||
allowed := false
|
|
||||||
|
|
||||||
// Handle wildcard origin
|
|
||||||
if allowedOrigins == "*" {
|
|
||||||
allowed = true
|
|
||||||
} else {
|
|
||||||
for _, allowedOrigin := range strings.Split(allowedOrigins, ",") {
|
|
||||||
if strings.TrimSpace(allowedOrigin) == origin {
|
|
||||||
allowed = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if allowed {
|
|
||||||
if allowedOrigins == "*" {
|
|
||||||
c.Header("Access-Control-Allow-Origin", "*")
|
|
||||||
} else {
|
|
||||||
c.Header("Access-Control-Allow-Origin", origin)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set other CORS headers
|
|
||||||
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
|
||||||
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With")
|
|
||||||
c.Header("Access-Control-Allow-Credentials", "true")
|
|
||||||
c.Header("Access-Control-Max-Age", "86400") // 24 hours
|
|
||||||
|
|
||||||
if c.Request.Method == "OPTIONS" {
|
|
||||||
c.AbortWithStatus(204)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.Next()
|
|
||||||
})
|
|
||||||
|
|
||||||
// Enhanced health check endpoint
|
|
||||||
r.GET("/health", func(c *gin.Context) {
|
|
||||||
// Check database connection
|
|
||||||
db := config.GetDB()
|
|
||||||
dbStatus := "connected"
|
|
||||||
var dbPingTime time.Duration = 0
|
|
||||||
|
|
||||||
if db == nil {
|
|
||||||
dbStatus = "disconnected"
|
|
||||||
} else {
|
|
||||||
sqlDB, err := db.DB()
|
|
||||||
if err != nil {
|
|
||||||
dbStatus = "error"
|
|
||||||
} else {
|
|
||||||
start := time.Now()
|
|
||||||
if err := sqlDB.Ping(); err != nil {
|
|
||||||
dbStatus = "error"
|
|
||||||
} else {
|
|
||||||
dbPingTime = time.Since(start)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check session store
|
|
||||||
sessionStatus := "ok"
|
|
||||||
// Since sessionStore is package-private in middleware, we assume it's initialized
|
|
||||||
// if we've reached this point in the code execution
|
|
||||||
|
|
||||||
// Get system stats
|
|
||||||
var memStats runtime.MemStats
|
|
||||||
runtime.ReadMemStats(&memStats)
|
|
||||||
|
|
||||||
uptime := time.Since(startTime)
|
|
||||||
|
|
||||||
health := gin.H{
|
|
||||||
"status": "ok",
|
|
||||||
"message": "Trackeep API is running",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"uptime": uptime.String(),
|
|
||||||
"database": gin.H{
|
|
||||||
"status": dbStatus,
|
|
||||||
"ping_time": dbPingTime.String(),
|
|
||||||
},
|
|
||||||
"sessions": gin.H{
|
|
||||||
"status": sessionStatus,
|
|
||||||
},
|
|
||||||
"system": gin.H{
|
|
||||||
"goroutines": runtime.NumGoroutine(),
|
|
||||||
"memory": gin.H{
|
|
||||||
"alloc_mb": memStats.Alloc / 1024 / 1024,
|
|
||||||
"total_alloc_mb": memStats.TotalAlloc / 1024 / 1024,
|
|
||||||
"sys_mb": memStats.Sys / 1024 / 1024,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"timestamp": gin.H{
|
|
||||||
"human": time.Now().Format(time.RFC3339),
|
|
||||||
"unix": time.Now().Unix(),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine overall health status
|
|
||||||
overallStatus := "healthy"
|
|
||||||
if dbStatus != "connected" {
|
|
||||||
overallStatus = "degraded"
|
|
||||||
health["status"] = "degraded"
|
|
||||||
}
|
|
||||||
if sessionStatus != "ok" {
|
|
||||||
overallStatus = "degraded"
|
|
||||||
health["status"] = "degraded"
|
|
||||||
}
|
|
||||||
|
|
||||||
statusCode := 200
|
|
||||||
if overallStatus == "degraded" {
|
|
||||||
statusCode = 503
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(statusCode, health)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Readiness probe endpoint (for Kubernetes/container orchestration)
|
|
||||||
r.GET("/ready", func(c *gin.Context) {
|
|
||||||
db := config.GetDB()
|
|
||||||
if db == nil {
|
|
||||||
c.JSON(503, gin.H{"status": "not_ready", "reason": "database_not_connected"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
sqlDB, err := db.DB()
|
|
||||||
if err != nil || sqlDB.Ping() != nil {
|
|
||||||
c.JSON(503, gin.H{"status": "not_ready", "reason": "database_ping_failed"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Assume session store is initialized if we're running
|
|
||||||
c.JSON(200, gin.H{"status": "ready"})
|
|
||||||
})
|
|
||||||
|
|
||||||
// API configuration endpoint for frontend
|
|
||||||
r.GET("/api/v1/config", func(c *gin.Context) {
|
|
||||||
// Get the current request info to determine base URL
|
|
||||||
scheme := "http"
|
|
||||||
if c.Request.TLS != nil {
|
|
||||||
scheme = "https"
|
|
||||||
}
|
|
||||||
|
|
||||||
host := c.Request.Host
|
|
||||||
if host == "" {
|
|
||||||
// Fallback to environment or localhost
|
|
||||||
host = os.Getenv("HOST")
|
|
||||||
if host == "" {
|
|
||||||
host = "localhost:8080"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
apiURL := fmt.Sprintf("%s://%s/api/v1", scheme, host)
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"api_url": apiURL,
|
|
||||||
"demo_mode": os.Getenv("VITE_DEMO_MODE") == "true",
|
|
||||||
"version": "1.0.0",
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// Liveness probe endpoint (for Kubernetes/container orchestration)
|
|
||||||
r.GET("/live", func(c *gin.Context) {
|
|
||||||
c.JSON(200, gin.H{"status": "alive", "timestamp": time.Now().Unix()})
|
|
||||||
})
|
|
||||||
|
|
||||||
// Demo status endpoint
|
// Demo status endpoint
|
||||||
r.GET("/api/demo/status", handlers.DemoStatus)
|
r.GET("/api/demo/status", handlers.DemoStatus)
|
||||||
@@ -363,36 +175,7 @@ func main() {
|
|||||||
github.GET("/repos", handlers.GetGitHubRepos)
|
github.GET("/repos", handlers.GetGitHubRepos)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Public YouTube search test endpoint (no auth required for testing)
|
v1.POST("/youtube-search-test", handlers.YouTubeSearchTest)
|
||||||
v1.POST("/youtube-search-test", func(c *gin.Context) {
|
|
||||||
var req struct {
|
|
||||||
Query string `json:"query" binding:"required"`
|
|
||||||
MaxResults int `json:"max_results"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.MaxResults <= 0 {
|
|
||||||
req.MaxResults = 5
|
|
||||||
}
|
|
||||||
|
|
||||||
// Search videos using the YouTube service
|
|
||||||
response, err := services.SearchYouTubeVideos(req.Query, req.MaxResults, "")
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
|
||||||
"error": "Failed to search YouTube videos",
|
|
||||||
"details": err.Error(),
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"success": true,
|
|
||||||
"data": response,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// Protected auth routes (with demo mode protection)
|
// Protected auth routes (with demo mode protection)
|
||||||
authProtected := v1.Group("/auth")
|
authProtected := v1.Group("/auth")
|
||||||
@@ -910,41 +693,30 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start server with graceful shutdown
|
|
||||||
port := os.Getenv("PORT")
|
|
||||||
if port == "" {
|
|
||||||
port = "8080"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create HTTP server with custom configuration
|
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: ":" + port,
|
Addr: ":" + cfg.Server.Port,
|
||||||
Handler: r,
|
Handler: r,
|
||||||
ReadTimeout: 15 * time.Second,
|
ReadTimeout: cfg.Server.ReadTimeout,
|
||||||
WriteTimeout: 15 * time.Second,
|
WriteTimeout: cfg.Server.WriteTimeout,
|
||||||
IdleTimeout: 60 * time.Second,
|
IdleTimeout: cfg.Server.IdleTimeout,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start server in a goroutine
|
|
||||||
go func() {
|
go func() {
|
||||||
log.Printf("Server starting on port %s", port)
|
log.Printf("Server starting on port %s", cfg.Server.Port)
|
||||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
log.Fatal("Failed to start server:", err)
|
log.Fatal("Failed to start server:", err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Wait for interrupt signal to gracefully shutdown the server
|
|
||||||
quit := make(chan os.Signal, 1)
|
quit := make(chan os.Signal, 1)
|
||||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||||
<-quit
|
<-quit
|
||||||
log.Println("Shutting down server...")
|
log.Println("Shutting down server...")
|
||||||
|
|
||||||
// Cleanup sessions
|
|
||||||
middleware.CleanupSessionsOnShutdown()
|
middleware.CleanupSessionsOnShutdown()
|
||||||
log.Println("Sessions cleaned up")
|
log.Println("Sessions cleaned up")
|
||||||
|
|
||||||
// Create a deadline for shutdown
|
ctx, cancel := context.WithTimeout(context.Background(), cfg.Server.ShutdownTimeout)
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
// Attempt graceful shutdown
|
// Attempt graceful shutdown
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func CORSMiddleware() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
allowedOrigins := os.Getenv("CORS_ALLOWED_ORIGINS")
|
||||||
|
ginMode := os.Getenv("GIN_MODE")
|
||||||
|
|
||||||
|
if allowedOrigins == "" {
|
||||||
|
if ginMode == "release" {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{
|
||||||
|
"error": "CORS not configured for production",
|
||||||
|
"message": "Please set CORS_ALLOWED_ORIGINS environment variable",
|
||||||
|
})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
allowedOrigins = "http://localhost:5173,http://localhost:3000,http://localhost:8080"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
origin := c.Request.Header.Get("Origin")
|
||||||
|
allowed := false
|
||||||
|
|
||||||
|
if allowedOrigins == "*" {
|
||||||
|
allowed = true
|
||||||
|
} else {
|
||||||
|
for _, allowedOrigin := range strings.Split(allowedOrigins, ",") {
|
||||||
|
if strings.TrimSpace(allowedOrigin) == origin {
|
||||||
|
allowed = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if allowed {
|
||||||
|
if allowedOrigins == "*" {
|
||||||
|
c.Header("Access-Control-Allow-Origin", "*")
|
||||||
|
} else {
|
||||||
|
c.Header("Access-Control-Allow-Origin", origin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||||
|
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With")
|
||||||
|
c.Header("Access-Control-Allow-Credentials", "true")
|
||||||
|
c.Header("Access-Control-Max-Age", "86400")
|
||||||
|
|
||||||
|
if c.Request.Method == "OPTIONS" {
|
||||||
|
c.AbortWithStatus(204)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ErrorCode string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ErrInternal ErrorCode = "INTERNAL_ERROR"
|
||||||
|
ErrBadRequest ErrorCode = "BAD_REQUEST"
|
||||||
|
ErrUnauthorized ErrorCode = "UNAUTHORIZED"
|
||||||
|
ErrForbidden ErrorCode = "FORBIDDEN"
|
||||||
|
ErrNotFound ErrorCode = "NOT_FOUND"
|
||||||
|
ErrConflict ErrorCode = "CONFLICT"
|
||||||
|
ErrValidation ErrorCode = "VALIDATION_ERROR"
|
||||||
|
ErrRateLimit ErrorCode = "RATE_LIMIT_EXCEEDED"
|
||||||
|
ErrServiceUnavailable ErrorCode = "SERVICE_UNAVAILABLE"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AppError struct {
|
||||||
|
Code ErrorCode `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Details string `json:"details,omitempty"`
|
||||||
|
HTTPStatus int `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *AppError) Error() string {
|
||||||
|
if e.Details != "" {
|
||||||
|
return fmt.Sprintf("%s: %s (%s)", e.Code, e.Message, e.Details)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s: %s", e.Code, e.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAppError(code ErrorCode, message string, httpStatus int) *AppError {
|
||||||
|
return &AppError{
|
||||||
|
Code: code,
|
||||||
|
Message: message,
|
||||||
|
HTTPStatus: httpStatus,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAppErrorWithDetails(code ErrorCode, message string, httpStatus int, details string) *AppError {
|
||||||
|
return &AppError{
|
||||||
|
Code: code,
|
||||||
|
Message: message,
|
||||||
|
Details: details,
|
||||||
|
HTTPStatus: httpStatus,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func WrapError(err error, code ErrorCode, message string, httpStatus int) *AppError {
|
||||||
|
return &AppError{
|
||||||
|
Code: code,
|
||||||
|
Message: message,
|
||||||
|
Details: err.Error(),
|
||||||
|
HTTPStatus: httpStatus,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func IsAppError(err error) (*AppError, bool) {
|
||||||
|
if appErr, ok := err.(*AppError); ok {
|
||||||
|
return appErr, true
|
||||||
|
}
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func BadRequest(message string) *AppError {
|
||||||
|
return NewAppError(ErrBadRequest, message, http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
func BadRequestWithDetails(message, details string) *AppError {
|
||||||
|
return NewAppErrorWithDetails(ErrBadRequest, message, http.StatusBadRequest, details)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Unauthorized(message string) *AppError {
|
||||||
|
return NewAppError(ErrUnauthorized, message, http.StatusUnauthorized)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Forbidden(message string) *AppError {
|
||||||
|
return NewAppError(ErrForbidden, message, http.StatusForbidden)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NotFound(message string) *AppError {
|
||||||
|
return NewAppError(ErrNotFound, message, http.StatusNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Conflict(message string) *AppError {
|
||||||
|
return NewAppError(ErrConflict, message, http.StatusConflict)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidationErr(message string) *AppError {
|
||||||
|
return NewAppError(ErrValidation, message, http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
func InternalError(message string) *AppError {
|
||||||
|
return NewAppError(ErrInternal, message, http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
|
||||||
|
func InternalErrorWithDetails(message, details string) *AppError {
|
||||||
|
return NewAppErrorWithDetails(ErrInternal, message, http.StatusInternalServerError, details)
|
||||||
|
}
|
||||||
|
|
||||||
|
func RateLimitExceeded(message string) *AppError {
|
||||||
|
return NewAppError(ErrRateLimit, message, http.StatusTooManyRequests)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ServiceUnavailable(message string) *AppError {
|
||||||
|
return NewAppError(ErrServiceUnavailable, message, http.StatusServiceUnavailable)
|
||||||
|
}
|
||||||
+4
-4
@@ -2,16 +2,16 @@ services:
|
|||||||
postgres:
|
postgres:
|
||||||
image: postgres:15-alpine
|
image: postgres:15-alpine
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: trackeep
|
POSTGRES_DB: ${POSTGRES_DB:-trackeep}
|
||||||
POSTGRES_USER: trackeep
|
POSTGRES_USER: ${POSTGRES_USER:-trackeep}
|
||||||
POSTGRES_PASSWORD: trackeep123
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
|
||||||
ports:
|
ports:
|
||||||
- "5432:5432"
|
- "5432:5432"
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql/data
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U trackeep -d trackeep"]
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-trackeep} -d ${POSTGRES_DB:-trackeep}"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
# OAuth Integration for Updates
|
||||||
|
|
||||||
|
This document explains how the Trackeep update system integrates with the OAuth service to use user's GitHub tokens for checking updates from the **Dvorinka/Trackeep** repository.
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
### 1. OAuth Service Integration
|
||||||
|
The update system requires the OAuth service to be running and automatically extracts GitHub tokens:
|
||||||
|
|
||||||
|
1. **User Authentication**: User logs in via the OAuth service (`@Others/oauth-service`)
|
||||||
|
2. **Token Storage**: OAuth service stores the GitHub access token in the JWT
|
||||||
|
3. **Update Check**: Update system extracts the token from the JWT for GitHub API calls
|
||||||
|
4. **Repository**: Checks for updates from `https://github.com/Dvorinka/Trackeep`
|
||||||
|
|
||||||
|
### 2. Token Extraction Process
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Function: getGitHubTokenFromContext()
|
||||||
|
// 1. Extract Authorization header from request
|
||||||
|
// 2. Parse JWT token using JWT_SECRET
|
||||||
|
// 3. Extract GitHub access token from claims
|
||||||
|
// 4. Validate token expiration
|
||||||
|
// 5. Return token for GitHub API calls
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. OAuth Service Requirement
|
||||||
|
|
||||||
|
The update system **requires** the OAuth service to be running:
|
||||||
|
- No fallback to environment tokens
|
||||||
|
- Clear error messages when OAuth service is unavailable
|
||||||
|
- Automatic token management through OAuth
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Required Environment Variables
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# OAuth Service (REQUIRED)
|
||||||
|
OAUTH_SERVICE_URL=http://localhost:9090
|
||||||
|
JWT_SECRET=your-jwt-secret-key
|
||||||
|
|
||||||
|
# Update Configuration
|
||||||
|
APP_VERSION=1.0.0
|
||||||
|
AUTO_UPDATE_CHECK=false
|
||||||
|
UPDATE_CHECK_INTERVAL=24h
|
||||||
|
PRERELEASE_UPDATES=false
|
||||||
|
```
|
||||||
|
|
||||||
|
### OAuth Service Setup
|
||||||
|
|
||||||
|
The OAuth service must be running and configured with:
|
||||||
|
- GitHub OAuth App credentials
|
||||||
|
- JWT signing secret matching backend
|
||||||
|
- Proper CORS configuration
|
||||||
|
- Access to `Dvorinka/Trackeep` repository
|
||||||
|
|
||||||
|
## API Flow
|
||||||
|
|
||||||
|
### Update Check Request
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/updates/check
|
||||||
|
Authorization: Bearer <jwt_token_from_oauth>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
|
||||||
|
If OAuth service is not available:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "OAuth service not available",
|
||||||
|
"message": "Please ensure OAuth service is running and you are authenticated"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
HTTP Status: `503 Service Unavailable`
|
||||||
|
|
||||||
|
## Benefits
|
||||||
|
|
||||||
|
### For Users
|
||||||
|
- **Zero Configuration**: No need to create GitHub tokens
|
||||||
|
- **Automatic Updates**: Seamless update checking through OAuth
|
||||||
|
- **Security**: Tokens managed securely by OAuth service
|
||||||
|
|
||||||
|
### For Developers
|
||||||
|
- **Centralized Authentication**: Single OAuth service for all instances
|
||||||
|
- **Repository Control**: Updates from your specific repository
|
||||||
|
- **No Token Management**: OAuth handles token lifecycle automatically
|
||||||
|
|
||||||
|
## Repository Configuration
|
||||||
|
|
||||||
|
The update system is configured to check:
|
||||||
|
- **Repository**: `Dvorinka/Trackeep`
|
||||||
|
- **API Endpoint**: `https://api.github.com/repos/Dvorinka/Trackeep/releases/latest`
|
||||||
|
- **Assets**: Platform-specific release assets
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
1. **OAuth Required**: No fallback authentication methods
|
||||||
|
2. **Token Validation**: JWT tokens are validated before use
|
||||||
|
3. **Expiration Check**: Tokens are checked for expiration
|
||||||
|
4. **Repository Access**: OAuth service needs access to your repository
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
1. **"OAuth service not available"**
|
||||||
|
- Ensure OAuth service is running on port 9090
|
||||||
|
- Check JWT_SECRET matches between services
|
||||||
|
- Verify user is authenticated via OAuth
|
||||||
|
|
||||||
|
2. **"No GitHub token found"**
|
||||||
|
- User needs to authenticate with OAuth service
|
||||||
|
- Check Authorization header is present
|
||||||
|
- Verify JWT token contains access_token claim
|
||||||
|
|
||||||
|
3. **"Failed to check for updates"**
|
||||||
|
- Verify repository exists: `Dvorinka/Trackeep`
|
||||||
|
- Check OAuth service has repository access
|
||||||
|
- Ensure GitHub releases are published
|
||||||
|
|
||||||
|
### Debug Logging
|
||||||
|
|
||||||
|
The system provides clear logging:
|
||||||
|
```
|
||||||
|
Using GitHub token from OAuth service for update check
|
||||||
|
No GitHub token from OAuth service - update check failed
|
||||||
|
OAuth service not available - please ensure OAuth service is running
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deployment Requirements
|
||||||
|
|
||||||
|
### Production Setup
|
||||||
|
|
||||||
|
1. **OAuth Service**: Must be deployed and accessible
|
||||||
|
2. **Environment Variables**: JWT_SECRET must match between services
|
||||||
|
3. **GitHub Releases**: Publish releases to `Dvorinka/Trackeep`
|
||||||
|
4. **Repository Access**: OAuth app needs access to your repository
|
||||||
|
|
||||||
|
### Service Dependencies
|
||||||
|
|
||||||
|
```
|
||||||
|
Trackeep Backend → OAuth Service → GitHub API
|
||||||
|
↓ ↓ ↓
|
||||||
|
Updates Authentication Release Data
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implementation Details
|
||||||
|
|
||||||
|
### Repository URL
|
||||||
|
|
||||||
|
```go
|
||||||
|
owner := "Dvorinka"
|
||||||
|
repo := "Trackeep"
|
||||||
|
url := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", owner, repo)
|
||||||
|
```
|
||||||
|
|
||||||
|
### OAuth Token Extraction
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Extract from JWT claims
|
||||||
|
claims := token.Claims.(jwt.MapClaims)
|
||||||
|
githubToken := claims["access_token"].(string)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error Response
|
||||||
|
|
||||||
|
```go
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||||
|
"error": "OAuth service not available",
|
||||||
|
"message": "Please ensure OAuth service is running and you are authenticated",
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Migration from Manual Tokens
|
||||||
|
|
||||||
|
### Before (Manual Setup)
|
||||||
|
```bash
|
||||||
|
# Required manual token creation
|
||||||
|
GITHUB_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxx
|
||||||
|
```
|
||||||
|
|
||||||
|
### After (OAuth Required)
|
||||||
|
```bash
|
||||||
|
# Zero configuration - OAuth handles everything
|
||||||
|
OAUTH_SERVICE_URL=http://localhost:9090
|
||||||
|
JWT_SECRET=shared-secret
|
||||||
|
```
|
||||||
|
|
||||||
|
## Release Process
|
||||||
|
|
||||||
|
To publish updates:
|
||||||
|
|
||||||
|
1. **Create Release**: Publish release on `Dvorinka/Trackeep`
|
||||||
|
2. **Platform Assets**: Include platform-specific binaries
|
||||||
|
3. **Release Notes**: Add changelog and checksum information
|
||||||
|
4. **Automatic Detection**: OAuth-enabled instances will detect updates automatically
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
// Enable demo mode - run this in browser console
|
||||||
|
localStorage.setItem('demoMode', 'true');
|
||||||
|
document.title = 'Trackeep - Demo Mode';
|
||||||
|
console.log('Demo mode enabled! Refresh the page to see changes.');
|
||||||
@@ -6,7 +6,9 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test": "echo \"No tests yet\" && exit 0",
|
||||||
|
"test:ci": "echo \"No tests yet\" && exit 0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kobalte/core": "^0.13.11",
|
"@kobalte/core": "^0.13.11",
|
||||||
|
|||||||
@@ -20,18 +20,18 @@ const iconPaths: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const fallbackIcons: Record<string, string> = {
|
const fallbackIcons: Record<string, string> = {
|
||||||
mistral: '🇪🇺',
|
mistral: 'M',
|
||||||
longcat: '🐱',
|
longcat: 'C',
|
||||||
grok: '🐦',
|
grok: 'G',
|
||||||
deepseek: '🔍',
|
deepseek: 'D',
|
||||||
ollama: '🦙',
|
ollama: 'O',
|
||||||
openrouter: '🌀',
|
openrouter: 'OR',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function AIProviderIcon(props: AIProviderIconProps) {
|
export function AIProviderIcon(props: AIProviderIconProps) {
|
||||||
const inlineSVG = createMemo(() => inlineSVGs[props.providerId]);
|
const inlineSVG = createMemo(() => inlineSVGs[props.providerId]);
|
||||||
const iconPath = createMemo(() => iconPaths[props.providerId]);
|
const iconPath = createMemo(() => iconPaths[props.providerId]);
|
||||||
const fallbackIcon = createMemo(() => fallbackIcons[props.providerId] || '🤖');
|
const fallbackIcon = createMemo(() => fallbackIcons[props.providerId] || 'AI');
|
||||||
|
|
||||||
// Use inline SVG if available (for openrouter, ollama, grok)
|
// Use inline SVG if available (for openrouter, ollama, grok)
|
||||||
if (inlineSVG()) {
|
if (inlineSVG()) {
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ export const AuthenticationWarning = () => {
|
|||||||
size="lg"
|
size="lg"
|
||||||
onClick={handleDemoMode}
|
onClick={handleDemoMode}
|
||||||
>
|
>
|
||||||
🎭 Try Demo Mode
|
Try Demo Mode
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -164,16 +164,11 @@ export function AIChatPanel(props: AIChatPanelProps) {
|
|||||||
onClick={() => setShowModelPicker(!showModelPicker())}
|
onClick={() => setShowModelPicker(!showModelPicker())}
|
||||||
class="flex items-center gap-2 px-3 py-1.5 bg-muted hover:bg-muted/80 rounded-full text-xs transition-colors"
|
class="flex items-center gap-2 px-3 py-1.5 bg-muted hover:bg-muted/80 rounded-full text-xs transition-colors"
|
||||||
>
|
>
|
||||||
<Show when={aiModels.find(m => m.id === selectedModel())?.iconId}>
|
|
||||||
<AIProviderIcon
|
<AIProviderIcon
|
||||||
providerId={aiModels.find(m => m.id === selectedModel())?.iconId || 'longcat'}
|
providerId={aiModels.find(m => m.id === selectedModel())?.iconId || 'longcat'}
|
||||||
size="1rem"
|
size="1rem"
|
||||||
class="rounded-full"
|
class="rounded-full"
|
||||||
/>
|
/>
|
||||||
</Show>
|
|
||||||
<Show when={!aiModels.find(m => m.id === selectedModel())?.iconId}>
|
|
||||||
<div class="w-4 h-4 rounded-full bg-gradient-to-r from-blue-500 to-purple-500"></div>
|
|
||||||
</Show>
|
|
||||||
<span class="text-muted-foreground">
|
<span class="text-muted-foreground">
|
||||||
{aiModels.find(m => m.id === selectedModel())?.name?.split(' ')[0] || 'AI'}
|
{aiModels.find(m => m.id === selectedModel())?.name?.split(' ')[0] || 'AI'}
|
||||||
</span>
|
</span>
|
||||||
@@ -196,20 +191,11 @@ export function AIChatPanel(props: AIChatPanelProps) {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Show when={model.iconId}>
|
|
||||||
<AIProviderIcon
|
<AIProviderIcon
|
||||||
providerId={model.iconId!}
|
providerId={model.iconId!}
|
||||||
size="0.75rem"
|
size="0.75rem"
|
||||||
class="rounded-full flex-shrink-0"
|
class="rounded-full flex-shrink-0"
|
||||||
/>
|
/>
|
||||||
</Show>
|
|
||||||
<Show when={!model.iconId}>
|
|
||||||
<div class={`w-3 h-3 rounded-full flex-shrink-0 ${
|
|
||||||
model.provider === 'LongCat' ? 'bg-gradient-to-r from-orange-500 to-red-500' :
|
|
||||||
model.provider === 'OpenAI' ? 'bg-gradient-to-r from-green-500 to-emerald-500' :
|
|
||||||
'bg-gradient-to-r from-purple-500 to-pink-500'
|
|
||||||
}`}></div>
|
|
||||||
</Show>
|
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="font-medium truncate">{model.name}</div>
|
<div class="font-medium truncate">{model.name}</div>
|
||||||
<div class="text-muted-foreground text-xs truncate">{model.description}</div>
|
<div class="text-muted-foreground text-xs truncate">{model.description}</div>
|
||||||
@@ -224,7 +210,7 @@ export function AIChatPanel(props: AIChatPanelProps) {
|
|||||||
|
|
||||||
<div class="flex items-center gap-3 text-xs text-muted-foreground">
|
<div class="flex items-center gap-3 text-xs text-muted-foreground">
|
||||||
<span>{aiModels.find(m => m.id === selectedModel())?.provider || 'LongCat'}</span>
|
<span>{aiModels.find(m => m.id === selectedModel())?.provider || 'LongCat'}</span>
|
||||||
<a href="/app/settings" class="text-primary hover:underline">
|
<a href="/app/settings#ai" class="text-primary hover:underline">
|
||||||
AI settings
|
AI settings
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ export function FloatingAI(props: FloatingAIProps) {
|
|||||||
|
|
||||||
{/* AI Chat Modal */}
|
{/* AI Chat Modal */}
|
||||||
<Show when={props.isChatOpen}>
|
<Show when={props.isChatOpen}>
|
||||||
<div class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
<div class="fixed inset-0 bg-black/50 flex items-center justify-center z-50 mt-0 p-4">
|
||||||
<div class="bg-card border border-border rounded-lg shadow-xl max-w-md w-full max-h-[600px] flex flex-col" style="width: 420px;">
|
<div class="bg-card border border-border rounded-lg shadow-xl max-w-md w-full max-h-[600px] flex flex-col" style="width: 420px;">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div class="flex items-center justify-between p-4 border-b border-border bg-gradient-to-r from-primary/10 to-primary/5">
|
<div class="flex items-center justify-between p-4 border-b border-border bg-gradient-to-r from-primary/10 to-primary/5">
|
||||||
|
|||||||
@@ -15,9 +15,10 @@ import { useAuth } from '@/lib/auth'
|
|||||||
export interface HeaderProps {
|
export interface HeaderProps {
|
||||||
class?: string
|
class?: string
|
||||||
title?: string
|
title?: string
|
||||||
|
onMenuClick?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Header(_props: HeaderProps) {
|
export function Header(props: HeaderProps) {
|
||||||
const [showUploadModal, setShowUploadModal] = createSignal(false);
|
const [showUploadModal, setShowUploadModal] = createSignal(false);
|
||||||
const { authState, updateProfile } = useAuth();
|
const { authState, updateProfile } = useAuth();
|
||||||
|
|
||||||
@@ -55,8 +56,15 @@ export function Header(_props: HeaderProps) {
|
|||||||
<div class="flex justify-between px-6 pt-4 pb-4">
|
<div class="flex justify-between px-6 pt-4 pb-4">
|
||||||
{/* Left side */}
|
{/* Left side */}
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
{/* Mobile menu button */}
|
{/* Menu button */}
|
||||||
<button type="button" aria-haspopup="dialog" aria-expanded="false" data-closed="" class="inline-flex items-center justify-center rounded-md text-sm font-medium transition-shadow focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-inherit hover:bg-accent/50 hover:text-accent-foreground h-9 w-9 md:hidden mr-2">
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={props.onMenuClick}
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
aria-expanded="false"
|
||||||
|
data-closed=""
|
||||||
|
class="inline-flex items-center justify-center rounded-md text-sm font-medium transition-shadow focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-inherit hover:bg-accent/50 hover:text-accent-foreground h-9 w-9 mr-2"
|
||||||
|
>
|
||||||
<IconMenu2 class="size-6" />
|
<IconMenu2 class="size-6" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -79,10 +87,10 @@ export function Header(_props: HeaderProps) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowUploadModal(true)}
|
onClick={() => setShowUploadModal(true)}
|
||||||
class="inline-flex items-center justify-center rounded-md text-sm font-medium transition-shadow focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground shadow hover:bg-primary/90 h-9 px-4 py-2"
|
class="inline-flex items-center justify-center rounded-md text-sm font-medium transition-shadow focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground shadow hover:bg-primary/90 h-10 px-4 py-2 text-base"
|
||||||
>
|
>
|
||||||
<IconUpload class="size-4" />
|
<IconUpload class="size-4 mr-2" />
|
||||||
<span class="hidden sm:inline ml-2">Import a document</span>
|
Import a document
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Color switcher dropdown */}
|
{/* Color switcher dropdown */}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { children, createSignal, onMount } from 'solid-js'
|
|||||||
import { Sidebar } from './Sidebar'
|
import { Sidebar } from './Sidebar'
|
||||||
import { Header } from './Header'
|
import { Header } from './Header'
|
||||||
import { AIChatPanel } from './AIChatPanel'
|
import { AIChatPanel } from './AIChatPanel'
|
||||||
import { UpdateNotification } from '../ui/UpdateNotification'
|
|
||||||
import { IconBrain } from '@tabler/icons-solidjs'
|
import { IconBrain } from '@tabler/icons-solidjs'
|
||||||
|
|
||||||
export interface LayoutProps {
|
export interface LayoutProps {
|
||||||
@@ -15,6 +14,7 @@ export interface LayoutProps {
|
|||||||
export function Layout(props: LayoutProps) {
|
export function Layout(props: LayoutProps) {
|
||||||
const resolved = children(() => props.children)
|
const resolved = children(() => props.children)
|
||||||
const [isChatOpen, setIsChatOpen] = createSignal(false)
|
const [isChatOpen, setIsChatOpen] = createSignal(false)
|
||||||
|
const [isSidebarOpen, setIsSidebarOpen] = createSignal(false)
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
// Initialize dark mode from localStorage or system preference
|
// Initialize dark mode from localStorage or system preference
|
||||||
@@ -142,19 +142,33 @@ export function Layout(props: LayoutProps) {
|
|||||||
setIsChatOpen(!isChatOpen())
|
setIsChatOpen(!isChatOpen())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toggleSidebar = () => {
|
||||||
|
setIsSidebarOpen(!isSidebarOpen())
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeSidebar = () => {
|
||||||
|
setIsSidebarOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="min-h-screen font-sans text-sm font-400 bg-background text-foreground">
|
<div class="min-h-screen font-sans text-sm font-400 bg-background text-foreground">
|
||||||
{/* Update Notification - Above everything */}
|
|
||||||
<UpdateNotification />
|
|
||||||
|
|
||||||
<div class="flex flex-row h-screen min-h-0">
|
<div class="flex flex-row h-screen min-h-0 relative">
|
||||||
|
{/* Mobile Sidebar Overlay */}
|
||||||
|
{isSidebarOpen() && (
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 bg-black/50 z-40"
|
||||||
|
onClick={closeSidebar}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
<Sidebar />
|
<Sidebar isOpen={isSidebarOpen()} onClose={closeSidebar} />
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<div class="flex-1 min-h-0 flex flex-col">
|
<div class="flex-1 min-h-0 flex flex-col">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<Header title={props.title} />
|
<Header title={props.title} onMenuClick={toggleSidebar} />
|
||||||
|
|
||||||
{/* Page Content */}
|
{/* Page Content */}
|
||||||
<main class="flex-1 overflow-auto max-w-screen">
|
<main class="flex-1 overflow-auto max-w-screen">
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ import {
|
|||||||
IconCalendar,
|
IconCalendar,
|
||||||
IconLogout,
|
IconLogout,
|
||||||
IconBuilding,
|
IconBuilding,
|
||||||
IconPlus
|
IconPlus,
|
||||||
|
IconX
|
||||||
} from '@tabler/icons-solidjs'
|
} from '@tabler/icons-solidjs'
|
||||||
import { UpdateChecker } from '../ui/UpdateChecker'
|
import { UpdateChecker } from '../ui/UpdateChecker'
|
||||||
|
|
||||||
@@ -48,9 +49,11 @@ const mockWorkspaces = [
|
|||||||
|
|
||||||
export interface SidebarProps {
|
export interface SidebarProps {
|
||||||
class?: string
|
class?: string
|
||||||
|
isOpen?: boolean
|
||||||
|
onClose?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Sidebar(_props: SidebarProps) {
|
export function Sidebar(props: SidebarProps) {
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const [isWorkspaceDropdownOpen, setIsWorkspaceDropdownOpen] = createSignal(false)
|
const [isWorkspaceDropdownOpen, setIsWorkspaceDropdownOpen] = createSignal(false)
|
||||||
const [selectedWorkspace, setSelectedWorkspace] = createSignal(mockWorkspaces[0])
|
const [selectedWorkspace, setSelectedWorkspace] = createSignal(mockWorkspaces[0])
|
||||||
@@ -84,7 +87,20 @@ export function Sidebar(_props: SidebarProps) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="w-280px border-r border-r-border flex-shrink-0 hidden md:block bg-card">
|
<>
|
||||||
|
{/* Mobile Close Button - Above sidebar */}
|
||||||
|
<Show when={props.isOpen}>
|
||||||
|
<button
|
||||||
|
onClick={props.onClose}
|
||||||
|
class="fixed top-4 right-4 z-50 md:hidden inline-flex items-center justify-center rounded-md text-sm font-medium transition-shadow focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-inherit hover:bg-accent/50 hover:text-accent-foreground h-8 w-8"
|
||||||
|
>
|
||||||
|
<IconX class="size-4" />
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<div class={`fixed inset-y-0 left-0 z-50 w-280px border-r border-r-border flex-shrink-0 bg-card transform transition-transform duration-300 ease-in-out md:relative md:translate-x-0 ${
|
||||||
|
props.isOpen ? 'translate-x-0' : '-translate-x-full'
|
||||||
|
}`}>
|
||||||
<div class="flex h-full">
|
<div class="flex h-full">
|
||||||
<div class="h-full flex flex-col pb-6 flex-1 min-w-0">
|
<div class="h-full flex flex-col pb-6 flex-1 min-w-0">
|
||||||
{/* Organization Selector */}
|
{/* Organization Selector */}
|
||||||
@@ -261,5 +277,6 @@ export function Sidebar(_props: SidebarProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import { type BraveSearchResult } from '@/lib/brave-search';
|
|||||||
import { Card } from '@/components/ui/Card';
|
import { Card } from '@/components/ui/Card';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Input } from '@/components/ui/Input';
|
import { Input } from '@/components/ui/Input';
|
||||||
import { isEnvDemoMode, shouldUseRealSearch } from '@/lib/demo-mode';
|
import { isEnvDemoMode } from '@/lib/demo-mode';
|
||||||
import { getSearchProvider, getApiBaseUrl } from '@/lib/credentials';
|
import { getApiBaseUrl } from '@/lib/credentials';
|
||||||
|
|
||||||
export const BrowserSearch = () => {
|
export const BrowserSearch = () => {
|
||||||
const [searchQuery, setSearchQuery] = createSignal('');
|
const [searchQuery, setSearchQuery] = createSignal('');
|
||||||
@@ -15,19 +15,26 @@ export const BrowserSearch = () => {
|
|||||||
const [hasSearched, setHasSearched] = createSignal(false);
|
const [hasSearched, setHasSearched] = createSignal(false);
|
||||||
const [searchType, setSearchType] = createSignal<'web' | 'news'>('web');
|
const [searchType, setSearchType] = createSignal<'web' | 'news'>('web');
|
||||||
|
|
||||||
|
// Add debouncing and request cancellation
|
||||||
|
let searchTimeout: number | undefined;
|
||||||
|
let currentRequestId: number = 0;
|
||||||
|
|
||||||
// Check if we're in demo mode
|
// Check if we're in demo mode
|
||||||
const isDemo = () => {
|
const isDemo = () => {
|
||||||
return isEnvDemoMode();
|
return isEnvDemoMode();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check if we should use real search APIs
|
|
||||||
const shouldUseReal = () => {
|
|
||||||
return shouldUseRealSearch();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSearch = async () => {
|
const handleSearch = async () => {
|
||||||
const query = searchQuery().trim();
|
const query = searchQuery().trim();
|
||||||
if (!query) return;
|
if (!query || isLoading()) return;
|
||||||
|
|
||||||
|
// Cancel any existing timeout
|
||||||
|
if (searchTimeout) {
|
||||||
|
clearTimeout(searchTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Increment request ID for cancellation
|
||||||
|
const requestId = ++currentRequestId;
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
@@ -35,32 +42,10 @@ export const BrowserSearch = () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const isDemoMode = isDemo();
|
const isDemoMode = isDemo();
|
||||||
const useRealAPIs = shouldUseReal();
|
|
||||||
|
|
||||||
console.log(`[BrowserSearch] Demo mode: ${isDemoMode}, Use real APIs: ${useRealAPIs}`);
|
console.log(`[BrowserSearch] Demo mode: ${isDemoMode}`);
|
||||||
|
|
||||||
// If we have credentials and should use real APIs, try them first
|
// Always use backend API for search to avoid CORS issues
|
||||||
if (useRealAPIs) {
|
|
||||||
console.log('Using real search APIs...');
|
|
||||||
|
|
||||||
// Try the configured search provider first
|
|
||||||
const searchProvider = getSearchProvider();
|
|
||||||
console.log(`Using search provider: ${searchProvider}`);
|
|
||||||
|
|
||||||
if (searchProvider === 'brave' && import.meta.env.VITE_BRAVE_API_KEY) {
|
|
||||||
try {
|
|
||||||
const { searchBrave } = await import('@/lib/brave-search');
|
|
||||||
const results = await searchBrave(query, 8, searchType());
|
|
||||||
if (results && results.length > 0) {
|
|
||||||
setSearchResults(results);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('Brave Search failed:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try backend as fallback
|
|
||||||
const API_BASE_URL = getApiBaseUrl();
|
const API_BASE_URL = getApiBaseUrl();
|
||||||
const token = localStorage.getItem('token') ||
|
const token = localStorage.getItem('token') ||
|
||||||
localStorage.getItem('auth_token') ||
|
localStorage.getItem('auth_token') ||
|
||||||
@@ -68,6 +53,7 @@ export const BrowserSearch = () => {
|
|||||||
const endpoint = searchType() === 'news' ? '/api/v1/search/news' : '/api/v1/search/web';
|
const endpoint = searchType() === 'news' ? '/api/v1/search/news' : '/api/v1/search/web';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
console.log(`Using backend search API: ${API_BASE_URL}${endpoint}`);
|
||||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -79,15 +65,17 @@ export const BrowserSearch = () => {
|
|||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (data.results && data.results.length > 0) {
|
// Check if this request is still current
|
||||||
|
if (requestId === currentRequestId && data.results && data.results.length > 0) {
|
||||||
setSearchResults(data.results);
|
setSearchResults(data.results);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
console.warn('Backend search returned error:', response.status, response.statusText);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('Backend search failed:', err);
|
console.warn('Backend search failed:', err);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// In demo mode or as fallback, use the demo mode API interceptor
|
// In demo mode or as fallback, use the demo mode API interceptor
|
||||||
if (isDemoMode) {
|
if (isDemoMode) {
|
||||||
@@ -105,6 +93,8 @@ export const BrowserSearch = () => {
|
|||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
// Check if this request is still current
|
||||||
|
if (requestId === currentRequestId) {
|
||||||
// Handle demo mode response format
|
// Handle demo mode response format
|
||||||
const results = data.web?.results || data.news?.results || data.mixed?.results || data.results || [];
|
const results = data.web?.results || data.news?.results || data.mixed?.results || data.results || [];
|
||||||
if (results.length > 0) {
|
if (results.length > 0) {
|
||||||
@@ -112,6 +102,7 @@ export const BrowserSearch = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
console.warn('Demo API failed, falling back to mock results...');
|
console.warn('Demo API failed, falling back to mock results...');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,6 +118,8 @@ export const BrowserSearch = () => {
|
|||||||
|
|
||||||
if (apiFailed) {
|
if (apiFailed) {
|
||||||
console.warn('All search APIs failed, showing demo results:', errorMessage);
|
console.warn('All search APIs failed, showing demo results:', errorMessage);
|
||||||
|
// Check if this request is still current
|
||||||
|
if (requestId === currentRequestId) {
|
||||||
const mockResults: BraveSearchResult[] = [
|
const mockResults: BraveSearchResult[] = [
|
||||||
{
|
{
|
||||||
title: `${query} - Search Result 1`,
|
title: `${query} - Search Result 1`,
|
||||||
@@ -144,12 +137,19 @@ export const BrowserSearch = () => {
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
setSearchResults(mockResults);
|
setSearchResults(mockResults);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// Check if this request is still current before setting error
|
||||||
|
if (requestId === currentRequestId) {
|
||||||
setError('Search temporarily unavailable. Please try again later.');
|
setError('Search temporarily unavailable. Please try again later.');
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
// Only update loading state if this is still the current request
|
||||||
|
if (requestId === currentRequestId) {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createSignal, For, Show, onMount, onCleanup } from 'solid-js';
|
import { createSignal, For, Show, onMount } from 'solid-js';
|
||||||
import { IconSearch, IconFileText, IconBookmark, IconChecklist, IconNotebook, IconFolder } from '@tabler/icons-solidjs';
|
import { IconSearch, IconFileText, IconBookmark, IconChecklist, IconNotebook, IconFolder } from '@tabler/icons-solidjs';
|
||||||
import { Input } from '@/components/ui/Input';
|
import { Input } from '@/components/ui/Input';
|
||||||
import { Card } from '@/components/ui/Card';
|
import { Card } from '@/components/ui/Card';
|
||||||
@@ -146,10 +146,27 @@ export const QuickSearch = () => {
|
|||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
document.addEventListener('keydown', handleGlobalKeyDown);
|
document.addEventListener('keydown', handleGlobalKeyDown);
|
||||||
});
|
|
||||||
|
|
||||||
onCleanup(() => {
|
// Add click outside listener
|
||||||
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
|
if (isOpen()) {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
// Check if click is outside the search modal
|
||||||
|
if (!target.closest('.quick-search-modal')) {
|
||||||
|
setIsOpen(false);
|
||||||
|
setSearchQuery('');
|
||||||
|
setSearchResults([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('click', handleClickOutside);
|
||||||
|
|
||||||
|
// Cleanup function
|
||||||
|
return () => {
|
||||||
document.removeEventListener('keydown', handleGlobalKeyDown);
|
document.removeEventListener('keydown', handleGlobalKeyDown);
|
||||||
|
document.removeEventListener('click', handleClickOutside);
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -167,8 +184,15 @@ export const QuickSearch = () => {
|
|||||||
|
|
||||||
{/* Search Modal */}
|
{/* Search Modal */}
|
||||||
<Show when={isOpen()}>
|
<Show when={isOpen()}>
|
||||||
<div class="fixed inset-0 z-50 flex items-start justify-center pt-20 bg-black/50 backdrop-blur-sm">
|
<div class="fixed inset-0 z-[70] flex items-start justify-center pt-20 bg-black/50 backdrop-blur-sm quick-search-modal" onClick={(e) => {
|
||||||
<div class="w-full max-w-2xl mx-4">
|
// Close if clicking on the backdrop
|
||||||
|
if (e.target === e.currentTarget) {
|
||||||
|
setIsOpen(false);
|
||||||
|
setSearchQuery('');
|
||||||
|
setSearchResults([]);
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<div class="w-full max-w-2xl mx-4" onClick={(e) => e.stopPropagation()}>
|
||||||
<Card class="p-4 shadow-2xl">
|
<Card class="p-4 shadow-2xl">
|
||||||
{/* Search Input */}
|
{/* Search Input */}
|
||||||
<div class="flex items-center gap-3 mb-4">
|
<div class="flex items-center gap-3 mb-4">
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ export const ActivityFeed = (props: ActivityFeedProps) => {
|
|||||||
{
|
{
|
||||||
id: 'github_3',
|
id: 'github_3',
|
||||||
type: 'github_star' as const,
|
type: 'github_star' as const,
|
||||||
title: '⭐ trackeep gained new stars',
|
title: 'trackeep gained new stars',
|
||||||
description: 'Repository reached 245 stars',
|
description: 'Repository reached 245 stars',
|
||||||
timestamp: new Date(now.getTime() - 8 * 3600000).toISOString(),
|
timestamp: new Date(now.getTime() - 8 * 3600000).toISOString(),
|
||||||
source: 'github' as const,
|
source: 'github' as const,
|
||||||
|
|||||||
@@ -55,15 +55,15 @@ export const BookmarkModal = (props: BookmarkModalProps) => {
|
|||||||
<>
|
<>
|
||||||
{/* Backdrop */}
|
{/* Backdrop */}
|
||||||
{props.isOpen && (
|
{props.isOpen && (
|
||||||
<div class="fixed inset-0 bg-black/50 z-40 mt-0" onClick={props.onClose} />
|
<div class="fixed inset-0 bg-black/50 z-[60] mt-0" onClick={props.onClose} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Modal */}
|
{/* Modal */}
|
||||||
<div class={`fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-card border border-border rounded-lg shadow-xl transition-all duration-300 z-50 ${
|
<div class={`fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-card border border-border rounded-lg shadow-xl transition-all duration-300 z-[70] ${
|
||||||
props.isOpen ? 'opacity-100 scale-100' : 'opacity-0 scale-95 pointer-events-none'
|
props.isOpen ? 'opacity-100 scale-100' : 'opacity-0 scale-95 pointer-events-none'
|
||||||
}`} style="width: 500px; max-width: 90vw;">
|
}`} style="width: min(500px, 90vw); max-height: min(80vh, 600px); overflow-y: auto;">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div class="flex items-center justify-between p-6 border-b border-border">
|
<div class="flex items-center justify-between p-4 sm:p-6 border-b border-border">
|
||||||
<h3 class="text-lg font-semibold">Add New Bookmark</h3>
|
<h3 class="text-lg font-semibold">Add New Bookmark</h3>
|
||||||
<button
|
<button
|
||||||
onClick={props.onClose}
|
onClick={props.onClose}
|
||||||
@@ -74,7 +74,7 @@ export const BookmarkModal = (props: BookmarkModalProps) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div class="p-6 space-y-4">
|
<div class="p-4 sm:p-6 space-y-4">
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<Input
|
<Input
|
||||||
type="url"
|
type="url"
|
||||||
@@ -129,7 +129,7 @@ export const BookmarkModal = (props: BookmarkModalProps) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div class="flex justify-end gap-2 p-6 border-t border-border">
|
<div class="flex flex-col sm:flex-row justify-end gap-2 p-4 sm:p-6 border-t border-border">
|
||||||
<Button variant="outline" onClick={props.onClose}>
|
<Button variant="outline" onClick={props.onClose}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -0,0 +1,682 @@
|
|||||||
|
/* Color Picker Styling - Match Papra Design System */
|
||||||
|
|
||||||
|
/* Papra color tokens for color picker */
|
||||||
|
.bg-bg-white-0 {
|
||||||
|
background-color: hsl(var(--background));
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-text-sub-600 {
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-text-soft-400 {
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-text-strong-950 {
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-primary-base {
|
||||||
|
color: hsl(var(--primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-primary-darker {
|
||||||
|
color: hsl(var(--primary) / 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.border-stroke-soft-200 {
|
||||||
|
border-color: hsl(var(--border));
|
||||||
|
}
|
||||||
|
|
||||||
|
.border-stroke-white-0 {
|
||||||
|
border-color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ring-stroke-white-0 {
|
||||||
|
--tw-ring-color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ring-stroke-strong-950 {
|
||||||
|
--tw-ring-color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.shadow-custom-md {
|
||||||
|
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shadow-regular-xs {
|
||||||
|
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shadow-button-important-focus {
|
||||||
|
box-shadow: 0 0 0 3px hsl(var(--ring) / 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shadow-md {
|
||||||
|
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Text size utilities */
|
||||||
|
.text-label-sm {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
line-height: 1.25rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-paragraph-sm {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
line-height: 1.25rem;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Layout utilities */
|
||||||
|
.rounded-20 {
|
||||||
|
border-radius: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rounded-l-10 {
|
||||||
|
border-radius: 0.625rem 0 0 0.625rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rounded-r-10 {
|
||||||
|
border-radius: 0 0.625rem 0.625rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rounded-full {
|
||||||
|
border-radius: 9999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hue Slider Styling */
|
||||||
|
.hue-slider {
|
||||||
|
position: relative;
|
||||||
|
touch-action: none;
|
||||||
|
forced-color-adjust: none;
|
||||||
|
background: linear-gradient(
|
||||||
|
to right,
|
||||||
|
hsla(0, 100%, 50%, 1),
|
||||||
|
hsla(60, 100%, 50%, 1),
|
||||||
|
hsla(120, 100%, 50%, 1),
|
||||||
|
hsla(180, 100%, 50%, 1),
|
||||||
|
hsla(240, 100%, 50%, 1),
|
||||||
|
hsla(300, 100%, 50%, 1),
|
||||||
|
hsla(360, 100%, 50%, 1)
|
||||||
|
),
|
||||||
|
repeating-conic-gradient(
|
||||||
|
#fff 0 90deg,
|
||||||
|
rgba(0,0,0,.3) 0 180deg
|
||||||
|
) 0% -25%/6px 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hue-slider-thumb {
|
||||||
|
position: absolute;
|
||||||
|
touch-action: none;
|
||||||
|
forced-color-adjust: none;
|
||||||
|
z-index: 50;
|
||||||
|
cursor: grab;
|
||||||
|
transition: transform 0.15s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hue-slider-thumb:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
transform: translate(-50%, -50%) scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hue-slider-input {
|
||||||
|
border: 0;
|
||||||
|
clip: rect(0 0 0 0);
|
||||||
|
clip-path: inset(50%);
|
||||||
|
height: 100%;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 0;
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
white-space: nowrap;
|
||||||
|
opacity: 0.0001;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Input Group Styling */
|
||||||
|
.group\/input-wrapper {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group\/input-wrapper:hover .placeholder\:text-text-sub-600 {
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.group\/input-wrapper:has(input:focus) .placeholder\:text-text-sub-600 {
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button styling */
|
||||||
|
.group:hover .hover\:text-text-strong-950 {
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.group:hover .hover\:bg-bg-weak-50 {
|
||||||
|
background-color: hsl(var(--muted));
|
||||||
|
}
|
||||||
|
|
||||||
|
.group:hover .hover\:shadow-none {
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group:hover .hover\:ring-transparent {
|
||||||
|
--tw-ring-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-visible\:text-text-strong-950:focus-visible {
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-visible\:shadow-button-important-focus:focus-visible {
|
||||||
|
box-shadow: 0 0 0 3px hsl(var(--ring) / 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-visible\:ring-stroke-strong-950:focus-visible {
|
||||||
|
--tw-ring-color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Focus states */
|
||||||
|
.focus-within\:z-10:focus-within {
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-within\:before\:ring-stroke-soft-200:focus-within:before {
|
||||||
|
--tw-ring-color: hsl(var(--border));
|
||||||
|
}
|
||||||
|
|
||||||
|
.has-\[input\:focus\]\:shadow-button-important-focus:has(input:focus) {
|
||||||
|
box-shadow: 0 0 0 3px hsl(var(--ring) / 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.has-\[input\:focus\]\:before\:ring-stroke-strong-950:has(input:focus):before {
|
||||||
|
--tw-ring-color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hover states */
|
||||||
|
.hover\:\[&\:not\(\:has\(input\:focus\)\)\:has\(\>\\:only-child\)\]\:before\:ring-transparent:hover:not(:has(input:focus)):has(> :only-child):before {
|
||||||
|
--tw-ring-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hover\:\[&\:not\(\:focus-within\)\]\:before\:\!ring-stroke-soft-200:hover:not(:focus-within):before {
|
||||||
|
--tw-ring-color: hsl(var(--border)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Underline styling */
|
||||||
|
.underline {
|
||||||
|
text-decoration-line: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.decoration-transparent {
|
||||||
|
text-decoration-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.underline-offset-\[3px\] {
|
||||||
|
text-underline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hover\:decoration-current:hover {
|
||||||
|
text-decoration-color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-visible\:underline:focus-visible {
|
||||||
|
text-decoration-line: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode specific styling */
|
||||||
|
[data-kb-theme="dark"] .bg-bg-white-0 {
|
||||||
|
background-color: hsl(var(--card));
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-kb-theme="dark"] .text-text-sub-600 {
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-kb-theme="dark"] .text-text-soft-400 {
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-kb-theme="dark"] .text-text-strong-950 {
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-kb-theme="dark"] .border-stroke-soft-200 {
|
||||||
|
border-color: #262626;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-kb-theme="dark"] .shadow-custom-md {
|
||||||
|
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.3), 0 2px 4px -2px rgb(0 0 0 / 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-kb-theme="dark"] .shadow-regular-xs {
|
||||||
|
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-kb-theme="dark"] .shadow-md {
|
||||||
|
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.3), 0 2px 4px -2px rgb(0 0 0 / 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Input field dark mode */
|
||||||
|
[data-kb-theme="dark"] .group\/input-wrapper:hover .hover\:\[&\:not\(\&\:has\(input\:focus\)\)\]\:bg-bg-weak-50:hover:not(&:has(input:focus)) {
|
||||||
|
background-color: hsl(var(--muted));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button dark mode */
|
||||||
|
[data-kb-theme="dark"] .group:hover .hover\:bg-bg-weak-50 {
|
||||||
|
background-color: hsl(var(--muted));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Color swatch selection ring */
|
||||||
|
.border-\[\#335CFF1F\] {
|
||||||
|
border-color: rgba(51, 92, 255, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Size utilities */
|
||||||
|
.size-3 {
|
||||||
|
width: 0.75rem;
|
||||||
|
height: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.size-5 {
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.max-w-\[316px\] {
|
||||||
|
max-width: 316px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Spacing utilities */
|
||||||
|
.gap-2\.5 {
|
||||||
|
gap: 0.625rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gap-3 {
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gap-4 {
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.space-y-3 > :not([hidden]) ~ :not([hidden]) {
|
||||||
|
--un-space-y-reverse: 0;
|
||||||
|
margin-top: calc(0.75rem * calc(1 - var(--un-space-y-reverse)));
|
||||||
|
}
|
||||||
|
|
||||||
|
.-space-x-px > :not([hidden]) ~ :not([hidden]) {
|
||||||
|
--un-space-x-reverse: 0;
|
||||||
|
margin-right: calc(-1px * calc(1 - var(--un-space-x-reverse)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Padding utilities */
|
||||||
|
.px-5 {
|
||||||
|
padding-left: 1.25rem;
|
||||||
|
padding-right: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.py-4 {
|
||||||
|
padding-top: 1rem;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pb-2\.5 {
|
||||||
|
padding-bottom: 0.625rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pb-5 {
|
||||||
|
padding-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pt-4 {
|
||||||
|
padding-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.py-1 {
|
||||||
|
padding-top: 0.25rem;
|
||||||
|
padding-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-0 {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.px-2\.5 {
|
||||||
|
padding-left: 0.625rem;
|
||||||
|
padding-right: 0.625rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.px-4 {
|
||||||
|
padding-left: 1rem;
|
||||||
|
padding-right: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.py-3\.5 {
|
||||||
|
padding-top: 0.875rem;
|
||||||
|
padding-bottom: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Position utilities */
|
||||||
|
.absolute {
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
.relative {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inset-0 {
|
||||||
|
inset: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inset-x-1\.5 {
|
||||||
|
left: 0.375rem;
|
||||||
|
right: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-1\/2 {
|
||||||
|
top: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.-inset-\[5px\] {
|
||||||
|
inset: -5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.-translate-y-1\/2 {
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Z-index utilities */
|
||||||
|
.z-50 {
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.z-10 {
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Display utilities */
|
||||||
|
.flex {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-flex {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.w-full {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h-full {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h-3 {
|
||||||
|
height: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h-2 {
|
||||||
|
height: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.w-2 {
|
||||||
|
width: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h-5 {
|
||||||
|
height: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h-6 {
|
||||||
|
height: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.w-6 {
|
||||||
|
width: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h-9 {
|
||||||
|
height: 2.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.w-9 {
|
||||||
|
width: 2.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.max-w-\[57px\] {
|
||||||
|
max-width: 57px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex-1 {
|
||||||
|
flex: 1 1 0%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex-\[2\] {
|
||||||
|
flex: 2 2 0%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Overflow utilities */
|
||||||
|
.overflow-hidden {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cursor utilities */
|
||||||
|
.cursor-text {
|
||||||
|
cursor: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cursor-pointer {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Transition utilities */
|
||||||
|
.transition {
|
||||||
|
transition: all 0.15s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duration-200 {
|
||||||
|
transition-duration: 200ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ease-out {
|
||||||
|
transition-timing-function: cubic-bezier(0, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Outline utilities */
|
||||||
|
.outline-none {
|
||||||
|
outline: 2px solid transparent;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Whitespace utilities */
|
||||||
|
.whitespace-nowrap {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Text alignment */
|
||||||
|
.text-center {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.justify-center {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.justify-between {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.items-center {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Flex direction */
|
||||||
|
.flex-col {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ring utilities */
|
||||||
|
.ring-1 {
|
||||||
|
--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
|
||||||
|
--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);
|
||||||
|
box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ring-0 {
|
||||||
|
--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
|
||||||
|
--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color);
|
||||||
|
box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ring-inset {
|
||||||
|
--tw-ring-inset: inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ring-transparent {
|
||||||
|
--tw-ring-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Border utilities */
|
||||||
|
.divide-x > :not([hidden]) ~ :not([hidden]) {
|
||||||
|
--un-divide-x-reverse: 0;
|
||||||
|
border-right-width: calc(1px * calc(1 - var(--un-divide-x-reverse)));
|
||||||
|
}
|
||||||
|
|
||||||
|
.border-b {
|
||||||
|
border-bottom-width: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Background utilities */
|
||||||
|
.bg-transparent {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-none {
|
||||||
|
background-image: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Disabled states */
|
||||||
|
.disabled\:pointer-events-none:disabled {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.disabled\:bg-bg-weak-50:disabled {
|
||||||
|
background-color: hsl(var(--muted));
|
||||||
|
}
|
||||||
|
|
||||||
|
.disabled\:text-text-disabled-300:disabled {
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.disabled\:ring-transparent:disabled {
|
||||||
|
--tw-ring-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.disabled\:shadow-none:disabled {
|
||||||
|
box-shadow: 0 0 #0000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.disabled\:no-underline:disabled {
|
||||||
|
text-decoration-line: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Placeholder utilities */
|
||||||
|
.placeholder\:select-none::placeholder {
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder\:text-text-soft-400::placeholder {
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder\:transition::placeholder {
|
||||||
|
transition-property: color, background-color, border-color, text-decoration-color, fill, stroke;
|
||||||
|
transition-duration: 200ms;
|
||||||
|
transition-timing-function: cubic-bezier(0, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder\:duration-200::placeholder {
|
||||||
|
transition-duration: 200ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder\:ease-out::placeholder {
|
||||||
|
transition-timing-function: cubic-bezier(0, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Group hover states */
|
||||||
|
.group:hover .group-hover\/input-wrapper\:placeholder\:text-text-sub-600::placeholder {
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Group focus states */
|
||||||
|
.group:has(input:focus) .group-has-\[input\:focus\]\:placeholder\:text-text-sub-600::placeholder {
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Before pseudo-element utilities */
|
||||||
|
.before\:absolute::before {
|
||||||
|
content: var(--tw-content);
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
.before\:inset-0::before {
|
||||||
|
content: var(--tw-content);
|
||||||
|
inset: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.before\:ring-1::before {
|
||||||
|
content: var(--tw-content);
|
||||||
|
--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
|
||||||
|
--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);
|
||||||
|
box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000);
|
||||||
|
}
|
||||||
|
|
||||||
|
.before\:ring-inset::before {
|
||||||
|
content: var(--tw-content);
|
||||||
|
--tw-ring-inset: inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.before\:ring-stroke-soft-200::before {
|
||||||
|
content: var(--tw-content);
|
||||||
|
--tw-ring-color: hsl(var(--border));
|
||||||
|
}
|
||||||
|
|
||||||
|
.before\:pointer-events-none::before {
|
||||||
|
content: var(--tw-content);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.before\:rounded-\[inherit\]::before {
|
||||||
|
content: var(--tw-content);
|
||||||
|
border-radius: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.before\:transition::before {
|
||||||
|
content: var(--tw-content);
|
||||||
|
transition: all 0.15s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.before\:duration-200::before {
|
||||||
|
content: var(--tw-content);
|
||||||
|
transition-duration: 200ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.before\:ease-out::before {
|
||||||
|
content: var(--tw-content);
|
||||||
|
transition-timing-function: cubic-bezier(0, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.before\:ring-transparent::before {
|
||||||
|
content: var(--tw-content);
|
||||||
|
--tw-ring-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.before\:ring-stroke-strong-950::before {
|
||||||
|
content: var(--tw-content);
|
||||||
|
--tw-ring-color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
@@ -0,0 +1,320 @@
|
|||||||
|
import { createSignal, For, Show, createMemo } from 'solid-js';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import './ColorPicker.css';
|
||||||
|
|
||||||
|
export interface ColorPickerProps {
|
||||||
|
value?: string;
|
||||||
|
onChange?: (color: string) => void;
|
||||||
|
savedColors?: string[];
|
||||||
|
onSavedColorsChange?: (colors: string[]) => void;
|
||||||
|
class?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultSavedColors = [
|
||||||
|
'#FFFFFF', '#F5F5F5', '#EBEBEB', '#D1D1D1', '#A3A3A3', '#7B7B7B', '#5C5C5C', '#333333',
|
||||||
|
'#D5E2FF', '#97BAFF', '#335CFF', '#2547D0', '#182F8B'
|
||||||
|
];
|
||||||
|
|
||||||
|
export const ColorPicker = (props: ColorPickerProps) => {
|
||||||
|
const [currentColor, setCurrentColor] = createSignal(props.value || '#FF0000');
|
||||||
|
const [hue, setHue] = createSignal(0);
|
||||||
|
const [alpha, setAlpha] = createSignal(100);
|
||||||
|
const [savedColors, setSavedColors] = createSignal(props.savedColors || defaultSavedColors);
|
||||||
|
const [isEditing, setIsEditing] = createSignal(false);
|
||||||
|
const [isDragging, setIsDragging] = createSignal(false);
|
||||||
|
|
||||||
|
// Parse hex color to get HSL values
|
||||||
|
const hexToHSL = (hex: string) => {
|
||||||
|
const r = parseInt(hex.slice(1, 3), 16) / 255;
|
||||||
|
const g = parseInt(hex.slice(3, 5), 16) / 255;
|
||||||
|
const b = parseInt(hex.slice(5, 7), 16) / 255;
|
||||||
|
|
||||||
|
const max = Math.max(r, g, b);
|
||||||
|
const min = Math.min(r, g, b);
|
||||||
|
let h = 0, s = 0, l = (max + min) / 2;
|
||||||
|
|
||||||
|
if (max !== min) {
|
||||||
|
const d = max - min;
|
||||||
|
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||||
|
switch (max) {
|
||||||
|
case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break;
|
||||||
|
case g: h = ((b - r) / d + 2) / 6; break;
|
||||||
|
case b: h = ((r - g) / d + 4) / 6; break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { h: Math.round(h * 360), s: Math.round(s * 100), l: Math.round(l * 100) };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Convert HSL to hex
|
||||||
|
const hslToHex = (h: number, s: number, l: number) => {
|
||||||
|
s /= 100;
|
||||||
|
l /= 100;
|
||||||
|
const a = s * Math.min(l, 1 - l);
|
||||||
|
const f = (n: number) => {
|
||||||
|
const k = (n + h / 30) % 12;
|
||||||
|
const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
|
||||||
|
return Math.round(255 * color).toString(16).padStart(2, '0');
|
||||||
|
};
|
||||||
|
return `#${f(0)}${f(8)}${f(4)}`.toUpperCase();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update color based on hue and current saturation/lightness
|
||||||
|
const updateColorFromHue = (newHue: number) => {
|
||||||
|
const currentHSL = hexToHSL(currentColor());
|
||||||
|
const newHex = hslToHex(newHue, currentHSL.s, currentHSL.l);
|
||||||
|
setCurrentColor(newHex);
|
||||||
|
setHue(newHue);
|
||||||
|
props.onChange?.(newHex);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle hex input change
|
||||||
|
const handleHexChange = (value: string) => {
|
||||||
|
const hexValue = value.startsWith('#') ? value : `#${value}`;
|
||||||
|
if (/^#[0-9A-F]{6}$/i.test(hexValue)) {
|
||||||
|
setCurrentColor(hexValue);
|
||||||
|
const hsl = hexToHSL(hexValue);
|
||||||
|
setHue(hsl.h);
|
||||||
|
props.onChange?.(hexValue);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle alpha input change
|
||||||
|
const handleAlphaChange = (value: string) => {
|
||||||
|
const numValue = parseInt(value.replace('%', ''));
|
||||||
|
if (!isNaN(numValue) && numValue >= 0 && numValue <= 100) {
|
||||||
|
setAlpha(numValue);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle hue slider drag
|
||||||
|
const handleSliderMouseDown = (e: MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragging(true);
|
||||||
|
updateHueFromPosition(e);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateHueFromPosition = (e: MouseEvent) => {
|
||||||
|
const slider = e.currentTarget as HTMLElement;
|
||||||
|
const rect = slider.getBoundingClientRect();
|
||||||
|
const x = e.clientX - rect.left;
|
||||||
|
const percentage = Math.max(0, Math.min(1, x / rect.width));
|
||||||
|
const newHue = Math.round(percentage * 360);
|
||||||
|
updateColorFromHue(newHue);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
|
if (isDragging()) {
|
||||||
|
updateHueFromPosition(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
setIsDragging(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle saved color selection
|
||||||
|
const handleSavedColorClick = (color: string) => {
|
||||||
|
setCurrentColor(color);
|
||||||
|
const hsl = hexToHSL(color);
|
||||||
|
setHue(hsl.h);
|
||||||
|
props.onChange?.(color);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle delete color
|
||||||
|
const handleDeleteColor = () => {
|
||||||
|
// Implementation for delete functionality
|
||||||
|
console.log('Delete color');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle add new color
|
||||||
|
const handleAddNewColor = () => {
|
||||||
|
const newColors = [...savedColors(), currentColor()];
|
||||||
|
setSavedColors(newColors);
|
||||||
|
props.onSavedColorsChange?.(newColors);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Format hex display
|
||||||
|
const hexDisplay = createMemo(() => currentColor());
|
||||||
|
|
||||||
|
// Set up global mouse events for dragging
|
||||||
|
document.addEventListener('mousemove', handleMouseMove);
|
||||||
|
document.addEventListener('mouseup', handleMouseUp);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class={cn("w-full max-w-[316px] overflow-hidden rounded-20 bg-bg-white-0 shadow-custom-md", props.class)}>
|
||||||
|
{/* Header */}
|
||||||
|
<div class="flex w-full items-center justify-between px-5 py-4 pb-2.5">
|
||||||
|
<div class="text-label-sm text-text-sub-600">Choose color</div>
|
||||||
|
<div class="text-label-sm text-text-soft-400">{hexDisplay()}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hue Slider */}
|
||||||
|
<div class="border-b border-stroke-soft-200 px-5 pb-5">
|
||||||
|
<div class="py-1 h-3 !p-0" data-rac="" data-orientation="horizontal">
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
class="w-full h-full rounded-full hue-slider"
|
||||||
|
aria-label="Hue"
|
||||||
|
onMouseDown={handleSliderMouseDown}
|
||||||
|
>
|
||||||
|
<div class="absolute inset-x-1.5 h-full">
|
||||||
|
<div
|
||||||
|
class="z-50 size-3 ring-stroke-white-0 shadow-md top-1/2 h-2 w-2 -translate-y-1/2 rounded-full !bg-bg-white-0 ring-0 hue-slider-thumb"
|
||||||
|
style={{
|
||||||
|
left: `${(hue() / 360) * 100}%`,
|
||||||
|
transform: 'translate(-50%, -50%)',
|
||||||
|
'background-color': currentColor()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
tabindex="0"
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="360"
|
||||||
|
step="1"
|
||||||
|
aria-orientation="horizontal"
|
||||||
|
aria-valuetext={`${hue()}°`}
|
||||||
|
class="hue-slider-input"
|
||||||
|
value={hue()}
|
||||||
|
onInput={(e) => updateColorFromHue(parseInt(e.target.value))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Color and Alpha Inputs */}
|
||||||
|
<div class="flex items-center gap-2.5 border-b border-stroke-soft-200 p-5">
|
||||||
|
<div class="flex flex-1 -space-x-px">
|
||||||
|
{/* Hex Input */}
|
||||||
|
<div class="group relative flex w-full overflow-hidden bg-bg-white-0 text-text-strong-950 shadow-regular-xs transition duration-200 ease-out divide-x divide-stroke-soft-200 before:absolute before:inset-0 before:ring-1 before:ring-inset before:ring-stroke-soft-200 before:pointer-events-none before:rounded-[inherit] before:transition before:duration-200 before:ease-out hover:shadow-none has-[input:focus]:shadow-button-important-focus has-[input:focus]:before:ring-stroke-strong-950 has-[input:disabled]:shadow-none has-[input:disabled]:before:ring-transparent rounded-lg hover:[&:not(:has(input:focus)):has(>:only-child)]:before:ring-transparent flex-[2] rounded-l-10 rounded-r-none focus-within:z-10 hover:[&:not(:focus-within)]:before:!ring-stroke-soft-200" data-rac="" data-channel="hex">
|
||||||
|
<label class="group/input-wrapper flex w-full cursor-text items-center bg-bg-white-0 transition duration-200 ease-out hover:[&:not(&:has(input:focus))]:bg-bg-weak-50 has-[input:disabled]:pointer-events-none has-[input:disabled]:bg-bg-weak-50 gap-2 px-2.5">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="h-3 w-3 shrink-0 rounded-full ring-0" style={{ 'background-color': currentColor() }}></div>
|
||||||
|
<input
|
||||||
|
id="hex-input"
|
||||||
|
type="text"
|
||||||
|
autocomplete="off"
|
||||||
|
role="textbox"
|
||||||
|
autocorrect="off"
|
||||||
|
spellcheck="false"
|
||||||
|
class="w-full bg-transparent bg-none text-paragraph-sm text-text-strong-950 outline-none transition duration-200 ease-out placeholder:select-none placeholder:text-text-soft-400 placeholder:transition placeholder:duration-200 placeholder:ease-out group-hover/input-wrapper:placeholder:text-text-sub-600 focus:outline-none group-has-[input:focus]:placeholder:text-text-sub-600 disabled:text-text-disabled-300 disabled:placeholder:text-text-disabled-300 h-5 items-start justify-start text-label-sm text-text-sub-600"
|
||||||
|
data-rac=""
|
||||||
|
value={hexDisplay()}
|
||||||
|
onInput={(e) => handleHexChange(e.target.value)}
|
||||||
|
title=""
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Alpha Input */}
|
||||||
|
<div class="group relative flex w-full overflow-hidden bg-bg-white-0 text-text-strong-950 shadow-regular-xs transition duration-200 ease-out divide-x divide-stroke-soft-200 before:absolute before:inset-0 before:ring-1 before:ring-inset before:ring-stroke-soft-200 before:pointer-events-none before:rounded-[inherit] before:transition before:duration-200 before:ease-out hover:shadow-none has-[input:focus]:shadow-button-important-focus has-[input:focus]:before:ring-stroke-strong-950 has-[input:disabled]:shadow-none has-[input:disabled]:before:ring-transparent rounded-lg hover:[&:not(:has(input:focus)):has(>:only-child)]:before:ring-transparent max-w-[57px] flex-1 rounded-l-none rounded-r-10 focus-within:z-10 hover:[&:not(:focus-within)]:before:!ring-stroke-soft-200" data-rac="" data-channel="alpha">
|
||||||
|
<label class="group/input-wrapper flex w-full cursor-text items-center bg-bg-white-0 transition duration-200 ease-out hover:[&:not(&:has(input:focus))]:bg-bg-weak-50 has-[input:disabled]:pointer-events-none has-[input:disabled]:bg-bg-weak-50 gap-2 px-2.5">
|
||||||
|
<input
|
||||||
|
aria-label="Alpha"
|
||||||
|
id="alpha-input"
|
||||||
|
type="text"
|
||||||
|
autocomplete="off"
|
||||||
|
inputmode="numeric"
|
||||||
|
aria-roledescription="Number field"
|
||||||
|
autocorrect="off"
|
||||||
|
spellcheck="false"
|
||||||
|
class="w-full bg-transparent bg-none text-paragraph-sm text-text-strong-950 outline-none transition duration-200 ease-out placeholder:select-none placeholder:text-text-soft-400 placeholder:transition placeholder:duration-200 placeholder:ease-out group-hover/input-wrapper:placeholder:text-text-sub-600 focus:outline-none group-has-[input:focus]:placeholder:text-text-sub-600 disabled:text-text-disabled-300 disabled:placeholder:text-text-disabled-300 h-9 text-label-sm text-text-sub-600"
|
||||||
|
data-rac=""
|
||||||
|
value={`${alpha()}%`}
|
||||||
|
onInput={(e) => handleAlphaChange(e.target.value)}
|
||||||
|
title=""
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delete Button */}
|
||||||
|
<button
|
||||||
|
class="group relative inline-flex items-center justify-center whitespace-nowrap outline-none transition duration-200 ease-out focus:outline-none disabled:pointer-events-none disabled:bg-bg-weak-50 disabled:text-text-disabled-300 disabled:ring-transparent ring-1 ring-inset h-9 gap-3 rounded-lg px-3 text-label-sm bg-bg-white-0 text-text-sub-600 shadow-regular-xs ring-stroke-soft-200 hover:bg-bg-weak-50 hover:text-text-strong-950 hover:shadow-none hover:ring-transparent focus-visible:text-text-strong-950 focus-visible:shadow-button-important-focus focus-visible:ring-stroke-strong-950 w-9"
|
||||||
|
onClick={handleDeleteColor}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="remixicon size-5 shrink-0">
|
||||||
|
<path d="M17 6H22V8H20V21C20 21.5523 19.5523 22 19 22H5C4.44772 22 4 21.5523 4 21V8H2V6H7V3C7 2.44772 7.44772 2 8 2H16C16.5523 2 17 2.44772 17 3V6ZM18 8H6V20H18V8ZM9 11H11V17H9V11ZM13 11H15V17H13V11ZM9 4V6H15V4H9Z"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Saved Colors */}
|
||||||
|
<div class="flex flex-col gap-4 border-b border-stroke-soft-200 px-5 pb-5 pt-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-label-sm text-text-sub-600">Saved colors</span>
|
||||||
|
<button
|
||||||
|
class="group inline-flex items-center justify-center whitespace-nowrap outline-none transition duration-200 ease-out underline decoration-transparent underline-offset-[3px] hover:decoration-current focus:outline-none focus-visible:underline disabled:pointer-events-none disabled:text-text-disabled-300 disabled:no-underline text-primary-base hover:text-primary-darker h-5 gap-1 text-label-sm"
|
||||||
|
onClick={() => setIsEditing(!isEditing())}
|
||||||
|
>
|
||||||
|
{isEditing() ? 'Done' : 'Edit'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<For each={savedColors().slice(0, 8)}>
|
||||||
|
{(color) => (
|
||||||
|
<button
|
||||||
|
class="relative h-6 w-6"
|
||||||
|
onClick={() => handleSavedColorClick(color)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 rounded-full border border-stroke-soft-200"
|
||||||
|
style={{ 'background-color': color }}
|
||||||
|
/>
|
||||||
|
<Show when={currentColor() === color}>
|
||||||
|
<div class="absolute -inset-[5px]">
|
||||||
|
<div class="absolute inset-0 rounded-full border-stroke-white-0"></div>
|
||||||
|
</div>
|
||||||
|
<div class="absolute -inset-[5px]">
|
||||||
|
<div class="absolute inset-0 rounded-full border-2 border-[#335CFF1F]"></div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<For each={savedColors().slice(8)}>
|
||||||
|
{(color) => (
|
||||||
|
<button
|
||||||
|
class="relative h-6 w-6"
|
||||||
|
onClick={() => handleSavedColorClick(color)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 rounded-full"
|
||||||
|
style={{ 'background-color': color }}
|
||||||
|
/>
|
||||||
|
<Show when={currentColor() === color}>
|
||||||
|
<div class="absolute -inset-[5px]">
|
||||||
|
<div class="absolute inset-0 rounded-full border-stroke-white-0"></div>
|
||||||
|
</div>
|
||||||
|
<div class="absolute -inset-[5px]">
|
||||||
|
<div class="absolute inset-0 rounded-full border-2 border-[#335CFF1F]"></div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add New Color Button */}
|
||||||
|
<button
|
||||||
|
class="flex w-full items-center justify-center gap-2 px-4 py-3.5 text-center"
|
||||||
|
onClick={handleAddNewColor}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="remixicon size-5 shrink-0 text-text-soft-400">
|
||||||
|
<path d="M11 11V5H13V11H19V13H13V19H11V13H5V11H11Z"></path>
|
||||||
|
</svg>
|
||||||
|
<span class="text-label-sm text-text-sub-600">Add new color</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -48,7 +48,7 @@ export const ConfirmModal = (props: ConfirmModalProps) => {
|
|||||||
<>
|
<>
|
||||||
{/* Backdrop */}
|
{/* Backdrop */}
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div class="fixed inset-0 bg-black/50 z-40" onClick={onClose} />
|
<div class="fixed inset-0 bg-black/50 z-40 mt-0" onClick={onClose} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Modal */}
|
{/* Modal */}
|
||||||
|
|||||||
@@ -0,0 +1,253 @@
|
|||||||
|
/* Date Range Picker Styling - Match Papra Design System */
|
||||||
|
|
||||||
|
/* Papra color tokens for date picker */
|
||||||
|
.bg-bg-white-0 {
|
||||||
|
background-color: hsl(var(--background));
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-text-sub-600 {
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-text-strong-950 {
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-text-soft-400 {
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-bg-weak-50 {
|
||||||
|
background-color: hsl(var(--muted));
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-static-white-0 {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-primary-base {
|
||||||
|
background-color: hsl(var(--primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-primary-base {
|
||||||
|
color: hsl(var(--primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-primary-alpha-10 {
|
||||||
|
background-color: hsl(var(--primary) / 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.border-stroke-soft-200 {
|
||||||
|
border-color: hsl(var(--border));
|
||||||
|
}
|
||||||
|
|
||||||
|
.shadow-regular-md {
|
||||||
|
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shadow-regular-xs {
|
||||||
|
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Text size utilities */
|
||||||
|
.text-label-sm {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
line-height: 1.25rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-paragraph-sm {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
line-height: 1.25rem;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Layout utilities */
|
||||||
|
.rounded-20 {
|
||||||
|
border-radius: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Date picker specific styling */
|
||||||
|
.rdp-button_reset {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
text-align: inherit;
|
||||||
|
text-decoration: inherit;
|
||||||
|
text-transform: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rdp-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.15s ease-in-out;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Day range selection styling */
|
||||||
|
.day-selected {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-range-start {
|
||||||
|
background-color: hsl(var(--primary));
|
||||||
|
color: white;
|
||||||
|
border-radius: 0.5rem 0 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-range-end {
|
||||||
|
background-color: hsl(var(--primary));
|
||||||
|
color: white;
|
||||||
|
border-radius: 0 0.5rem 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-range-middle {
|
||||||
|
background-color: hsl(var(--primary) / 0.1);
|
||||||
|
color: hsl(var(--primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-today {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Range selection background styling */
|
||||||
|
.group\/cell[has=".day-range-middle"] {
|
||||||
|
background-color: hsl(var(--primary) / 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.group\/cell[has=".day-range-start"]:not([has=".day-range-end"]) {
|
||||||
|
background-color: hsl(var(--primary) / 0.1);
|
||||||
|
border-radius: 0.5rem 0 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group\/cell[has=".day-range-end"]:not([has=".day-range-start"]) {
|
||||||
|
background-color: hsl(var(--primary) / 0.1);
|
||||||
|
border-radius: 0 0.5rem 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode specific styling */
|
||||||
|
[data-kb-theme="dark"] .bg-bg-white-0 {
|
||||||
|
background-color: hsl(var(--card));
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-kb-theme="dark"] .text-text-sub-600 {
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-kb-theme="dark"] .text-text-strong-950 {
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-kb-theme="dark"] .text-text-soft-400 {
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-kb-theme="dark"] .bg-bg-weak-50 {
|
||||||
|
background-color: hsl(var(--muted));
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-kb-theme="dark"] .border-stroke-soft-200 {
|
||||||
|
border-color: #262626;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-kb-theme="dark"] .shadow-regular-md {
|
||||||
|
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.3), 0 2px 4px -2px rgb(0 0 0 / 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-kb-theme="dark"] .shadow-regular-xs {
|
||||||
|
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive adjustments */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.sm\:w-\[632px\] {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 632px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md\:w-\[200px\] {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md\:border-b-0 {
|
||||||
|
border-bottom-width: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md\:border-r {
|
||||||
|
border-right-width: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.sm\:w-\[632px\] {
|
||||||
|
width: 632px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md\:w-\[200px\] {
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md\:border-b-0 {
|
||||||
|
border-bottom-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md\:border-r {
|
||||||
|
border-right-width: 1px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Calendar grid fixes */
|
||||||
|
.rdp table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rdp th,
|
||||||
|
.rdp td {
|
||||||
|
padding: 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button hover states */
|
||||||
|
.rdp-button:hover {
|
||||||
|
background-color: hsl(var(--accent));
|
||||||
|
color: hsl(var(--accent-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.rdp-button:focus-visible {
|
||||||
|
outline: 2px solid hsl(var(--ring));
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Selected day styling */
|
||||||
|
.rdp-button[aria-selected="true"] {
|
||||||
|
background-color: hsl(var(--primary));
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Range selection continuity */
|
||||||
|
.group\/cell[has=".day-range-middle"]::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: -2px;
|
||||||
|
width: 2px;
|
||||||
|
height: 100%;
|
||||||
|
background-color: hsl(var(--primary) / 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.group\/cell[has=".day-range-end"]::before {
|
||||||
|
left: 0;
|
||||||
|
right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group\/cell:last-child[has=".day-range-middle"]::before {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
@@ -0,0 +1,410 @@
|
|||||||
|
import { createSignal, For, Show, createMemo } from 'solid-js';
|
||||||
|
import { Portal } from 'solid-js/web';
|
||||||
|
import { IconChevronLeft, IconChevronRight, IconCalendar } from '@tabler/icons-solidjs';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import './DateRangePicker.css';
|
||||||
|
|
||||||
|
export interface DateRange {
|
||||||
|
start?: Date;
|
||||||
|
end?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DateRangePickerProps {
|
||||||
|
value?: DateRange;
|
||||||
|
onChange?: (range: DateRange | null) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
class?: string;
|
||||||
|
id?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const presetRanges = [
|
||||||
|
{ label: 'Today', value: { start: new Date(), end: new Date() } },
|
||||||
|
{
|
||||||
|
label: 'Last 7 days',
|
||||||
|
value: {
|
||||||
|
start: new Date(Date.now() - 6 * 24 * 60 * 60 * 1000),
|
||||||
|
end: new Date()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Last 30 days',
|
||||||
|
value: {
|
||||||
|
start: new Date(Date.now() - 29 * 24 * 60 * 60 * 1000),
|
||||||
|
end: new Date()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Last 3 months',
|
||||||
|
value: {
|
||||||
|
start: new Date(Date.now() - 89 * 24 * 60 * 60 * 1000),
|
||||||
|
end: new Date()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Last 12 months',
|
||||||
|
value: {
|
||||||
|
start: new Date(Date.now() - 364 * 24 * 60 * 60 * 1000),
|
||||||
|
end: new Date()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Month to date',
|
||||||
|
value: {
|
||||||
|
start: new Date(new Date().getFullYear(), new Date().getMonth(), 1),
|
||||||
|
end: new Date()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Year to date',
|
||||||
|
value: {
|
||||||
|
start: new Date(new Date().getFullYear(), 0, 1),
|
||||||
|
end: new Date()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export const DateRangePicker = (props: DateRangePickerProps) => {
|
||||||
|
const [isOpen, setIsOpen] = createSignal(false);
|
||||||
|
const [selectedRange, setSelectedRange] = createSignal<DateRange>(props.value || {});
|
||||||
|
const [currentMonth, setCurrentMonth] = createSignal(new Date());
|
||||||
|
const [position, setPosition] = createSignal({ top: 0, left: 0, width: 0 });
|
||||||
|
|
||||||
|
let triggerRef: HTMLButtonElement | undefined;
|
||||||
|
|
||||||
|
const months = [
|
||||||
|
'January', 'February', 'March', 'April', 'May', 'June',
|
||||||
|
'July', 'August', 'September', 'October', 'November', 'December'
|
||||||
|
];
|
||||||
|
|
||||||
|
const weekDays = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];
|
||||||
|
|
||||||
|
const getDaysInMonth = (date: Date) => {
|
||||||
|
return new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate();
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFirstDayOfMonth = (date: Date) => {
|
||||||
|
return new Date(date.getFullYear(), date.getMonth(), 1).getDay();
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateCalendarDays = () => {
|
||||||
|
const days = [];
|
||||||
|
const daysInMonth = getDaysInMonth(currentMonth());
|
||||||
|
const firstDay = getFirstDayOfMonth(currentMonth());
|
||||||
|
|
||||||
|
// Add empty cells for days before month starts
|
||||||
|
for (let i = 0; i < firstDay; i++) {
|
||||||
|
days.push(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add days of the month
|
||||||
|
for (let i = 1; i <= daysInMonth; i++) {
|
||||||
|
days.push(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
return days;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isDateInRange = (date: Date) => {
|
||||||
|
const range = selectedRange();
|
||||||
|
if (!range.start || !range.end) return false;
|
||||||
|
return date >= range.start && date <= range.end;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isDateStart = (date: Date) => {
|
||||||
|
const range = selectedRange();
|
||||||
|
return range.start?.toDateString() === date.toDateString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const isDateEnd = (date: Date) => {
|
||||||
|
const range = selectedRange();
|
||||||
|
return range.end?.toDateString() === date.toDateString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDateClass = (day: number) => {
|
||||||
|
const date = new Date(currentMonth().getFullYear(), currentMonth().getMonth(), day);
|
||||||
|
const isStart = isDateStart(date);
|
||||||
|
const isEnd = isDateEnd(date);
|
||||||
|
const inRange = isDateInRange(date);
|
||||||
|
|
||||||
|
let classes = "rdp-button_reset rdp-button flex h-10 w-full items-center justify-center rounded-lg text-center text-label-sm text-text-sub-600 outline-none transition duration-200 ease-out hover:bg-bg-weak-50 hover:text-text-strong-950 focus:outline-none focus-visible:bg-bg-weak-50 focus-visible:text-text-strong-950";
|
||||||
|
|
||||||
|
if (isStart && isEnd) {
|
||||||
|
classes += " aria-[selected]:bg-primary-base aria-[selected]:text-static-white";
|
||||||
|
} else if (isStart) {
|
||||||
|
classes += " aria-[selected]:bg-primary-base aria-[selected]:text-static-white day-selected day-range-start";
|
||||||
|
} else if (isEnd) {
|
||||||
|
classes += " aria-[selected]:bg-primary-base aria-[selected]:text-static-white day-selected day-range-end";
|
||||||
|
} else if (inRange) {
|
||||||
|
classes += " day-selected day-range-middle !text-primary-base !bg-transparent";
|
||||||
|
}
|
||||||
|
|
||||||
|
return classes;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCellClass = (day: number) => {
|
||||||
|
const date = new Date(currentMonth().getFullYear(), currentMonth().getMonth(), day);
|
||||||
|
const isStart = isDateStart(date);
|
||||||
|
const isEnd = isDateEnd(date);
|
||||||
|
const inRange = isDateInRange(date);
|
||||||
|
|
||||||
|
let classes = "group/cell relative h-10 w-full select-none p-0";
|
||||||
|
|
||||||
|
if (isStart && !isEnd) {
|
||||||
|
classes += " [&:has(.day-range-start):not(:has(.day-range-end))]:rounded-l-full [&:has(.day-range-start):not(:has(.day-range-end))]:bg-primary-alpha-10 [&:has(.day-range-start):not(:has(.day-range-end))]:before:block";
|
||||||
|
} else if (isEnd && !isStart) {
|
||||||
|
classes += " [&:has(.day-range-end):not(:has(.day-range-start))]:rounded-r-full [&:has(.day-range-end):not(:has(.day-range-start))]:bg-primary-alpha-10";
|
||||||
|
} else if (inRange) {
|
||||||
|
classes += " [&:has(.day-range-middle)]:bg-primary-alpha-10 [&:has(.day-range-middle)]:before:block";
|
||||||
|
}
|
||||||
|
|
||||||
|
classes += " [&:not(:has(+_*_[type=button]))]:before:hidden before:absolute before:inset-y-0 before:-right-2 before:hidden before:w-2 before:bg-primary-alpha-10 last:[&:has(.day-range-middle)]:before-hidden [&:has(.day-range-end)]:before:left-0 [&:has(.day-range-end)]:before:right-auto";
|
||||||
|
|
||||||
|
return classes;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDateClick = (day: number) => {
|
||||||
|
const clickedDate = new Date(currentMonth().getFullYear(), currentMonth().getMonth(), day);
|
||||||
|
const range = selectedRange();
|
||||||
|
|
||||||
|
if (!range.start || (range.start && range.end)) {
|
||||||
|
// Start new selection
|
||||||
|
setSelectedRange({ start: clickedDate, end: undefined });
|
||||||
|
} else {
|
||||||
|
// Complete the range
|
||||||
|
if (clickedDate < range.start) {
|
||||||
|
setSelectedRange({ start: clickedDate, end: range.start });
|
||||||
|
} else {
|
||||||
|
setSelectedRange({ start: range.start, end: clickedDate });
|
||||||
|
}
|
||||||
|
props.onChange?.(selectedRange());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDateHover = (day: number) => {
|
||||||
|
const date = new Date(currentMonth().getFullYear(), currentMonth().getMonth(), day);
|
||||||
|
const range = selectedRange();
|
||||||
|
|
||||||
|
if (range.start && !range.end) {
|
||||||
|
// Update preview range
|
||||||
|
if (date >= range.start) {
|
||||||
|
setSelectedRange({ ...range, end: date });
|
||||||
|
} else {
|
||||||
|
setSelectedRange({ start: date, end: range.start });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePresetClick = (preset: typeof presetRanges[0]) => {
|
||||||
|
setSelectedRange(preset.value);
|
||||||
|
props.onChange?.(preset.value);
|
||||||
|
setIsOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePrevMonth = () => {
|
||||||
|
setCurrentMonth(new Date(currentMonth().getFullYear(), currentMonth().getMonth() - 1));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNextMonth = () => {
|
||||||
|
setCurrentMonth(new Date(currentMonth().getFullYear(), currentMonth().getMonth() + 1));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleModal = () => {
|
||||||
|
if (props.disabled) return;
|
||||||
|
|
||||||
|
if (!isOpen()) {
|
||||||
|
if (!triggerRef) return;
|
||||||
|
|
||||||
|
const rect = triggerRef.getBoundingClientRect();
|
||||||
|
const estimatedHeight = 400;
|
||||||
|
|
||||||
|
let top = rect.bottom + window.scrollY + 4;
|
||||||
|
const viewportBottom = window.scrollY + window.innerHeight;
|
||||||
|
|
||||||
|
if (top + estimatedHeight > viewportBottom) {
|
||||||
|
top = rect.top + window.scrollY - estimatedHeight - 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
const width = 632; // Fixed width as per design
|
||||||
|
let left = rect.left + window.scrollX;
|
||||||
|
const maxLeft = window.scrollX + window.innerWidth - width - 16;
|
||||||
|
if (left > maxLeft) {
|
||||||
|
left = maxLeft;
|
||||||
|
}
|
||||||
|
if (left < window.scrollX + 16) {
|
||||||
|
left = window.scrollX + 16;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPosition({ top, left, width });
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsOpen(!isOpen());
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (date: Date) => {
|
||||||
|
return date.toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const displayRange = createMemo(() => {
|
||||||
|
const range = selectedRange();
|
||||||
|
if (range.start && range.end) {
|
||||||
|
return `${formatDate(range.start)} - ${formatDate(range.end)}`;
|
||||||
|
} else if (range.start) {
|
||||||
|
return `${formatDate(range.start)} - ...`;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleToggleModal}
|
||||||
|
disabled={props.disabled}
|
||||||
|
class={cn(
|
||||||
|
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 text-left",
|
||||||
|
props.class
|
||||||
|
)}
|
||||||
|
id={props.id || 'date-range-picker-button'}
|
||||||
|
ref={triggerRef}
|
||||||
|
>
|
||||||
|
<Show
|
||||||
|
when={selectedRange().start && selectedRange().end}
|
||||||
|
fallback={<span class="text-muted-foreground">{props.placeholder || "Select date range"}</span>}
|
||||||
|
>
|
||||||
|
<span>{displayRange()}</span>
|
||||||
|
</Show>
|
||||||
|
<IconCalendar class="ml-auto h-4 w-4 opacity-50" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Show when={isOpen()}>
|
||||||
|
<Portal>
|
||||||
|
{/* Close on outside click */}
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-[120]"
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="fixed z-[130] m-4 inline-flex w-fit flex-col overflow-hidden rounded-20 bg-bg-white-0 shadow-regular-md ring-1 ring-inset ring-stroke-soft-200 sm:w-[632px]"
|
||||||
|
style={{
|
||||||
|
top: `${position().top}px`,
|
||||||
|
left: `${position().left}px`,
|
||||||
|
width: `${position().width}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="flex h-full flex-col md:flex-row">
|
||||||
|
{/* Left Panel - Preset Ranges */}
|
||||||
|
<div class="space-y-2 px-4 py-5 sm:border-r sm:border-stroke-soft-200 w-full border-b md:w-[200px] md:border-b-0 md:border-r">
|
||||||
|
<div class="flex flex-row gap-2 overflow-x-auto md:flex-col md:overflow-x-visible">
|
||||||
|
<For each={presetRanges}>
|
||||||
|
{(preset) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handlePresetClick(preset)}
|
||||||
|
class="h-9 w-full rounded-lg px-3 text-left text-label-sm text-text-sub-600 transition duration-200 ease-out hover:bg-bg-weak-50 whitespace-nowrap md:whitespace-normal"
|
||||||
|
>
|
||||||
|
{preset.label}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Panel - Calendar */}
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex w-full flex-col">
|
||||||
|
<div class="rdp">
|
||||||
|
<div class="flex w-full">
|
||||||
|
<div class="space-y-2 p-5 w-full">
|
||||||
|
{/* Month Navigation */}
|
||||||
|
<div class="flex justify-center items-center relative rounded-lg bg-bg-weak-50 h-9">
|
||||||
|
<div class="text-label-sm text-text-sub-600 select-none">
|
||||||
|
{months[currentMonth().getMonth()]} {currentMonth().getFullYear()}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<button
|
||||||
|
onClick={handlePrevMonth}
|
||||||
|
class="rdp-button_reset rdp-button flex shrink-0 items-center justify-center outline-none transition duration-200 ease-out disabled:pointer-events-none disabled:border-transparent disabled:bg-transparent disabled:text-text-disabled-300 disabled:shadow-none focus:outline-none bg-bg-white-0 text-text-sub-600 shadow-regular-xs hover:bg-bg-weak-50 hover:text-text-strong-950 focus-visible:bg-bg-strong-950 focus-visible:text-text-white-0 size-6 rounded-md absolute top-1/2 -translate-y-1/2 left-1.5"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<IconChevronLeft class="size-5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleNextMonth}
|
||||||
|
class="rdp-button_reset rdp-button flex shrink-0 items-center justify-center outline-none transition duration-200 ease-out disabled:pointer-events-none disabled:border-transparent disabled:bg-transparent disabled:text-text-disabled-300 disabled:shadow-none focus:outline-none bg-bg-white-0 text-text-sub-600 shadow-regular-xs hover:bg-bg-weak-50 hover:text-text-strong-950 focus-visible:bg-bg-strong-950 focus-visible:text-text-white-0 size-6 rounded-md absolute top-1/2 -translate-y-1/2 right-1.5"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<IconChevronRight class="size-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Calendar Grid */}
|
||||||
|
<table class="w-full border-collapse flex justify-center items-center flex-col !mt-0">
|
||||||
|
<thead class="w-full">
|
||||||
|
<tr class="flex gap-2">
|
||||||
|
<For each={weekDays}>
|
||||||
|
{(day) => (
|
||||||
|
<th class="text-text-soft-400 text-label-sm uppercase size-10 flex items-center justify-center text-center select-none w-full mt-2">
|
||||||
|
{day}
|
||||||
|
</th>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="w-full">
|
||||||
|
{(() => {
|
||||||
|
const days = generateCalendarDays();
|
||||||
|
const weeks = [];
|
||||||
|
for (let i = 0; i < days.length; i += 7) {
|
||||||
|
weeks.push(days.slice(i, i + 7));
|
||||||
|
}
|
||||||
|
return weeks.map((weekDays) => (
|
||||||
|
<tr class="grid grid-flow-col auto-cols-fr w-full mt-2 gap-2">
|
||||||
|
<For each={weekDays}>
|
||||||
|
{(day) => (
|
||||||
|
<td class={getCellClass(day!)}>
|
||||||
|
<Show when={day !== null}>
|
||||||
|
<button
|
||||||
|
name="day"
|
||||||
|
class={getDateClass(day!)}
|
||||||
|
onClick={() => handleDateClick(day!)}
|
||||||
|
onMouseEnter={() => handleDateHover(day!)}
|
||||||
|
type="button"
|
||||||
|
aria-selected={isDateInRange(new Date(currentMonth().getFullYear(), currentMonth().getMonth(), day!))}
|
||||||
|
>
|
||||||
|
{day}
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</tr>
|
||||||
|
));
|
||||||
|
})()}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Range Display */}
|
||||||
|
<div class="flex items-center justify-between gap-4 border-t border-stroke-soft-200 p-4 pl-6">
|
||||||
|
<div class="text-paragraph-sm text-text-sub-600">
|
||||||
|
Range: <span class="text-label-sm text-text-strong-950">{displayRange()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Portal>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createSignal } from 'solid-js';
|
import { createSignal } from 'solid-js';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { IconX, IconDownload, IconExternalLink, IconEye, IconFile, IconCode, IconFileText } from '@tabler/icons-solidjs';
|
import { IconX, IconDownload, IconExternalLink, IconEye, IconFile, IconCode, IconFileText, IconAlertTriangle, IconMusic, IconFileDescription, IconChartBar, IconChartLine } from '@tabler/icons-solidjs';
|
||||||
|
|
||||||
interface FilePreviewModalProps {
|
interface FilePreviewModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -27,7 +27,7 @@ export const FilePreviewModal = (props: FilePreviewModalProps) => {
|
|||||||
return (
|
return (
|
||||||
<div class="w-full h-full bg-muted p-8 rounded flex items-center justify-center">
|
<div class="w-full h-full bg-muted p-8 rounded flex items-center justify-center">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<div class="text-6xl mb-4">⚠️</div>
|
<IconAlertTriangle class="size-12 mx-auto mb-4 text-muted-foreground" />
|
||||||
<p class="text-lg font-medium mb-2">Preview Failed</p>
|
<p class="text-lg font-medium mb-2">Preview Failed</p>
|
||||||
<p class="text-muted-foreground mb-4">Unable to load preview for this file</p>
|
<p class="text-muted-foreground mb-4">Unable to load preview for this file</p>
|
||||||
<Button onClick={() => window.open(file.downloadUrl || '#', '_blank')}>
|
<Button onClick={() => window.open(file.downloadUrl || '#', '_blank')}>
|
||||||
@@ -68,7 +68,7 @@ export const FilePreviewModal = (props: FilePreviewModalProps) => {
|
|||||||
Your browser does not support the audio element.
|
Your browser does not support the audio element.
|
||||||
</audio>
|
</audio>
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<div class="text-4xl mb-2">🎵</div>
|
<IconMusic class="size-8 mx-auto mb-2 text-muted-foreground" />
|
||||||
<p class="font-medium">{file.name}</p>
|
<p class="font-medium">{file.name}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -86,7 +86,7 @@ export const FilePreviewModal = (props: FilePreviewModalProps) => {
|
|||||||
return (
|
return (
|
||||||
<div class="w-full h-full bg-muted p-8 rounded flex items-center justify-center">
|
<div class="w-full h-full bg-muted p-8 rounded flex items-center justify-center">
|
||||||
<div class="text-center max-w-md">
|
<div class="text-center max-w-md">
|
||||||
<div class="text-6xl mb-4">📄</div>
|
<IconFileText class="size-12 mx-auto mb-4 text-muted-foreground" />
|
||||||
<p class="text-lg font-medium mb-2">PDF Document</p>
|
<p class="text-lg font-medium mb-2">PDF Document</p>
|
||||||
<p class="text-muted-foreground mb-4 truncate">{file.name}</p>
|
<p class="text-muted-foreground mb-4 truncate">{file.name}</p>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
@@ -106,8 +106,8 @@ export const FilePreviewModal = (props: FilePreviewModalProps) => {
|
|||||||
return (
|
return (
|
||||||
<div class="w-full h-full bg-muted p-8 rounded flex items-center justify-center">
|
<div class="w-full h-full bg-muted p-8 rounded flex items-center justify-center">
|
||||||
<div class="text-center max-w-md">
|
<div class="text-center max-w-md">
|
||||||
<div class="text-6xl mb-4">
|
<div class="size-12 mx-auto mb-4 text-muted-foreground flex items-center justify-center">
|
||||||
{file.type === 'docx' ? '📄' : file.type === 'pptx' ? '📊' : '📈'}
|
{file.type === 'docx' ? <IconFileDescription class="size-12" /> : file.type === 'pptx' ? <IconChartBar class="size-12" /> : <IconChartLine class="size-12" />}
|
||||||
</div>
|
</div>
|
||||||
<p class="text-lg font-medium mb-2">
|
<p class="text-lg font-medium mb-2">
|
||||||
{file.type === 'docx' ? 'Word Document' : file.type === 'pptx' ? 'PowerPoint Presentation' : 'Excel Spreadsheet'}
|
{file.type === 'docx' ? 'Word Document' : file.type === 'pptx' ? 'PowerPoint Presentation' : 'Excel Spreadsheet'}
|
||||||
@@ -193,7 +193,7 @@ export const FilePreviewModal = (props: FilePreviewModalProps) => {
|
|||||||
<>
|
<>
|
||||||
{/* Backdrop */}
|
{/* Backdrop */}
|
||||||
{props.isOpen && (
|
{props.isOpen && (
|
||||||
<div class="fixed inset-0 bg-black/50 z-40" onClick={props.onClose} />
|
<div class="fixed inset-0 bg-black/50 z-40 mt-0" onClick={props.onClose} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Modal */}
|
{/* Modal */}
|
||||||
|
|||||||
@@ -0,0 +1,938 @@
|
|||||||
|
/* File Upload Styling - Match Papra Design System */
|
||||||
|
|
||||||
|
/* Papra color tokens for file upload */
|
||||||
|
.bg-bg-white-0 {
|
||||||
|
background-color: hsl(var(--background));
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-text-sub-600 {
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-text-strong-950 {
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-text-soft-400 {
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-text-disabled-300 {
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.border-stroke-soft-200 {
|
||||||
|
border-color: hsl(var(--border));
|
||||||
|
}
|
||||||
|
|
||||||
|
.border-stroke-sub-300 {
|
||||||
|
border-color: hsl(var(--border));
|
||||||
|
}
|
||||||
|
|
||||||
|
.ring-stroke-soft-200 {
|
||||||
|
--tw-ring-color: hsl(var(--border));
|
||||||
|
}
|
||||||
|
|
||||||
|
.ring-stroke-strong-950 {
|
||||||
|
--tw-ring-color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.shadow-custom-md {
|
||||||
|
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shadow-regular-xs {
|
||||||
|
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shadow-button-important-focus {
|
||||||
|
box-shadow: 0 0 0 3px hsl(var(--ring) / 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Text size utilities */
|
||||||
|
.text-label-sm {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
line-height: 1.25rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-paragraph-xs {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1rem;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-paragraph-sm {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
line-height: 1.25rem;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-subheading-2xs {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
line-height: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Layout utilities */
|
||||||
|
.rounded-20 {
|
||||||
|
border-radius: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rounded-xl {
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rounded-2xl {
|
||||||
|
border-radius: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rounded-lg {
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rounded-md {
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rounded-full {
|
||||||
|
border-radius: 9999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rounded-10 {
|
||||||
|
border-radius: 0.625rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Size utilities */
|
||||||
|
.max-w-\[440px\] {
|
||||||
|
max-width: 440px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.size-10 {
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.size-5 {
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.size-6 {
|
||||||
|
width: 1.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.size-\[18px\] {
|
||||||
|
width: 1.125rem;
|
||||||
|
height: 1.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.w-full {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h-full {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h-10 {
|
||||||
|
height: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h-8 {
|
||||||
|
height: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h-1 {
|
||||||
|
height: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h-1\.5 {
|
||||||
|
height: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h-4 {
|
||||||
|
height: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h-6 {
|
||||||
|
height: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h-9 {
|
||||||
|
height: 2.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h-px {
|
||||||
|
height: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex-1 {
|
||||||
|
flex: 1 1 0%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex-shrink-0 {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Spacing utilities */
|
||||||
|
.gap-3\.5 {
|
||||||
|
gap: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gap-5 {
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gap-4 {
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gap-3 {
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gap-2\.5 {
|
||||||
|
gap: 0.625rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gap-2 {
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gap-1 {
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gap-px {
|
||||||
|
gap: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.space-y-4 > :not([hidden]) ~ :not([hidden]) {
|
||||||
|
--un-space-y-reverse: 0;
|
||||||
|
margin-top: calc(1rem * calc(1 - var(--un-space-y-reverse)));
|
||||||
|
}
|
||||||
|
|
||||||
|
.space-y-1 > :not([hidden]) ~ :not([hidden]) {
|
||||||
|
--un-space-y-reverse: 0;
|
||||||
|
margin-top: calc(0.25rem * calc(1 - var(--un-space-y-reverse)));
|
||||||
|
}
|
||||||
|
|
||||||
|
.space-y-1\.5 > :not([hidden]) ~ :not([hidden]) {
|
||||||
|
--un-space-y-reverse: 0;
|
||||||
|
margin-top: calc(0.375rem * calc(1 - var(--un-space-y-reverse)));
|
||||||
|
}
|
||||||
|
|
||||||
|
.my-6 {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Padding utilities */
|
||||||
|
.p-5 {
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-8 {
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-4 {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.py-4 {
|
||||||
|
padding-top: 1rem;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pl-5 {
|
||||||
|
padding-left: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pr-14 {
|
||||||
|
padding-right: 3.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pl-3\.5 {
|
||||||
|
padding-left: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.px-3 {
|
||||||
|
padding-left: 0.75rem;
|
||||||
|
padding-right: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.px-2\.5 {
|
||||||
|
padding-left: 0.625rem;
|
||||||
|
padding-right: 0.625rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.py-0\.5 {
|
||||||
|
padding-top: 0.125rem;
|
||||||
|
padding-bottom: 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.px-\[3px\] {
|
||||||
|
padding-left: 3px;
|
||||||
|
padding-right: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Position utilities */
|
||||||
|
.relative {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.absolute {
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-1\.5 {
|
||||||
|
bottom: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left-0 {
|
||||||
|
left: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.right-4 {
|
||||||
|
right: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-4 {
|
||||||
|
top: 1rem;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.inset-0 {
|
||||||
|
inset: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inset-x-0 {
|
||||||
|
left: 0px;
|
||||||
|
right: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Z-index utilities */
|
||||||
|
.z-50 {
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Display utilities */
|
||||||
|
.flex {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-flex {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.block {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cursor utilities */
|
||||||
|
.cursor-pointer {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cursor-text {
|
||||||
|
cursor: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Border utilities */
|
||||||
|
.border {
|
||||||
|
border-width: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.border-dashed {
|
||||||
|
border-style: dashed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.border-b {
|
||||||
|
border-bottom-width: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divide-x > :not([hidden]) ~ :not([hidden]) {
|
||||||
|
--un-divide-x-reverse: 0;
|
||||||
|
border-right-width: calc(1px * calc(1 - var(--un-divide-x-reverse)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ring utilities */
|
||||||
|
.ring-1 {
|
||||||
|
--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
|
||||||
|
--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);
|
||||||
|
box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ring-inset {
|
||||||
|
--tw-ring-inset: inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Background utilities */
|
||||||
|
.bg-transparent {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-none {
|
||||||
|
background-image: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-bg-weak-50 {
|
||||||
|
background-color: hsl(var(--muted));
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-bg-soft-200 {
|
||||||
|
background-color: hsl(var(--muted));
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-primary-alpha-10 {
|
||||||
|
background-color: hsl(var(--primary) / 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Color utilities */
|
||||||
|
.text-static-white {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-success-base {
|
||||||
|
color: hsl(142 76% 36%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-error-base {
|
||||||
|
color: hsl(0 84% 60%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-warning-base {
|
||||||
|
color: hsl(31 98% 50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-information-base {
|
||||||
|
color: hsl(199 89% 67%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-primary-base {
|
||||||
|
color: hsl(var(--primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-neutral-base {
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-success-base {
|
||||||
|
background-color: hsl(142 76% 36%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-error-base {
|
||||||
|
background-color: hsl(0 84% 60%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-warning-base {
|
||||||
|
background-color: hsl(31 98% 50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-neutral-base {
|
||||||
|
background-color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.border-primary-base {
|
||||||
|
border-color: hsl(var(--primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Outline utilities */
|
||||||
|
.outline-none {
|
||||||
|
outline: 2px solid transparent;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Transition utilities */
|
||||||
|
.transition {
|
||||||
|
transition: all 0.15s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duration-200 {
|
||||||
|
transition-duration: 200ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duration-300 {
|
||||||
|
transition-duration: 300ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ease-out {
|
||||||
|
transition-timing-function: cubic-bezier(0, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Transform utilities */
|
||||||
|
.translate-y-1\/2 {
|
||||||
|
transform: translateY(50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animation utilities */
|
||||||
|
.animate-spin {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-in {
|
||||||
|
animation-name: enter;
|
||||||
|
animation-duration: 0.15s;
|
||||||
|
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-out {
|
||||||
|
animation-name: exit;
|
||||||
|
animation-duration: 0.15s;
|
||||||
|
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-in-0 {
|
||||||
|
animation-name: fadeIn;
|
||||||
|
animation-duration: 0.15s;
|
||||||
|
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-out-0 {
|
||||||
|
animation-name: fadeOut;
|
||||||
|
animation-duration: 0.15s;
|
||||||
|
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoom-in-95 {
|
||||||
|
animation-name: zoomIn95;
|
||||||
|
animation-duration: 0.15s;
|
||||||
|
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoom-out-95 {
|
||||||
|
animation-name: zoomOut95;
|
||||||
|
animation-duration: 0.15s;
|
||||||
|
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeOut {
|
||||||
|
from {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes zoomIn95 {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes zoomOut95 {
|
||||||
|
from {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hover states */
|
||||||
|
.hover\:bg-bg-weak-50:hover {
|
||||||
|
background-color: hsl(var(--muted));
|
||||||
|
}
|
||||||
|
|
||||||
|
.hover\:text-text-strong-950:hover {
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.hover\:shadow-none:hover {
|
||||||
|
box-shadow: 0 0 #0000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hover\:ring-transparent:hover {
|
||||||
|
--tw-ring-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hover\:border-primary-base:hover {
|
||||||
|
border-color: hsl(var(--primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Focus states */
|
||||||
|
.focus\:outline-none:focus {
|
||||||
|
outline: 2px solid transparent;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-visible\:bg-bg-strong-950:focus-visible {
|
||||||
|
background-color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-visible\:text-text-white-0:focus-visible {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-visible\:shadow-button-important-focus:focus-visible {
|
||||||
|
box-shadow: 0 0 0 3px hsl(var(--ring) / 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-visible\:ring-stroke-strong-950:focus-visible {
|
||||||
|
--tw-ring-color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Disabled states */
|
||||||
|
.disabled\:pointer-events-none:disabled {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.disabled\:border-transparent:disabled {
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.disabled\:bg-transparent:disabled {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.disabled\:text-text-disabled-300:disabled {
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.disabled\:shadow-none:disabled {
|
||||||
|
box-shadow: 0 0 #0000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.disabled\:no-underline:disabled {
|
||||||
|
text-decoration-line: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Group hover states */
|
||||||
|
.group:hover .group-hover\/input-wrapper\:placeholder\:text-text-sub-600::placeholder {
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Group focus states */
|
||||||
|
.group:has(input:focus) .group-has-\[input\:focus\]\:placeholder\:text-text-sub-600::placeholder {
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Before pseudo-element utilities */
|
||||||
|
.before\:absolute::before {
|
||||||
|
content: var(--tw-content);
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
.before\:inset-x-0::before {
|
||||||
|
content: var(--tw-content);
|
||||||
|
left: 0px;
|
||||||
|
right: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.before\:bottom-0::before {
|
||||||
|
content: var(--tw-content);
|
||||||
|
bottom: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.before\:border-b::before {
|
||||||
|
content: var(--tw-content);
|
||||||
|
border-bottom-width: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.before\:ring-1::before {
|
||||||
|
content: var(--tw-content);
|
||||||
|
--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
|
||||||
|
--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);
|
||||||
|
box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000);
|
||||||
|
}
|
||||||
|
|
||||||
|
.before\:ring-inset::before {
|
||||||
|
content: var(--tw-content);
|
||||||
|
--tw-ring-inset: inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.before\:pointer-events-none::before {
|
||||||
|
content: var(--tw-content);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.before\:rounded-\[inherit\]::before {
|
||||||
|
content: var(--tw-content);
|
||||||
|
border-radius: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.before\:transition::before {
|
||||||
|
content: var(--tw-content);
|
||||||
|
transition: all 0.15s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.before\:duration-200::before {
|
||||||
|
content: var(--tw-content);
|
||||||
|
transition-duration: 200ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.before\:ease-out::before {
|
||||||
|
content: var(--tw-content);
|
||||||
|
transition-timing-function: cubic-bezier(0, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Has selector utilities */
|
||||||
|
.has-\[input\:focus\]\:shadow-button-important-focus:has(input:focus) {
|
||||||
|
box-shadow: 0 0 0 3px hsl(var(--ring) / 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.has-\[input\:focus\]\:before\:ring-stroke-strong-950:has(input:focus):before {
|
||||||
|
--tw-ring-color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.has-\[input\:disabled\]\:shadow-none:has(input:disabled) {
|
||||||
|
box-shadow: 0 0 #0000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.has-\[input\:disabled\]\:before\:ring-transparent:has(input:disabled):before {
|
||||||
|
--tw-ring-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Complex hover states */
|
||||||
|
.hover\:\[&\:not\(\:has\(input\:focus\)\)\:has\(\>\\:only-child\)\]\:before\:ring-transparent:hover:not(:has(input:focus)):has(> :only-child):before {
|
||||||
|
--tw-ring-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hover\:\[&\:not\(\:focus-within\)\]\:before\:\!ring-stroke-soft-200:hover:not(:focus-within):before {
|
||||||
|
--tw-ring-color: hsl(var(--border)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Group complex states */
|
||||||
|
.group\/input-wrapper:hover .hover\:\[&\:not\(\&\:has\(input\:focus\)\)\]\:bg-bg-weak-50:hover:not(&:has(input:focus)) {
|
||||||
|
background-color: hsl(var(--muted));
|
||||||
|
}
|
||||||
|
|
||||||
|
.group\/input-wrapper:has(input:disabled) .has-\[input\:disabled\]\:pointer-events-none {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group\/input-wrapper:has(input:disabled) .has-\[input\:disabled\]\:bg-bg-weak-50 {
|
||||||
|
background-color: hsl(var(--muted));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Placeholder utilities */
|
||||||
|
.placeholder\:select-none::placeholder {
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder\:text-text-soft-400::placeholder {
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder\:transition::placeholder {
|
||||||
|
transition-property: color, background-color, border-color, text-decoration-color, fill, stroke;
|
||||||
|
transition-duration: 200ms;
|
||||||
|
transition-timing-function: cubic-bezier(0, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder\:duration-200::placeholder {
|
||||||
|
transition-duration: 200ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder\:ease-out::placeholder {
|
||||||
|
transition-timing-function: cubic-bezier(0, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Progress bar */
|
||||||
|
[role="progressbar"] {
|
||||||
|
transform-origin: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* File type badges */
|
||||||
|
.text-\[11px\] {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leading-none {
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.font-semibold {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode specific styling */
|
||||||
|
[data-kb-theme="dark"] .bg-bg-white-0 {
|
||||||
|
background-color: hsl(var(--card));
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-kb-theme="dark"] .text-text-sub-600 {
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-kb-theme="dark"] .text-text-strong-950 {
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-kb-theme="dark"] .text-text-soft-400 {
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-kb-theme="dark"] .border-stroke-soft-200 {
|
||||||
|
border-color: #262626;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-kb-theme="dark"] .border-stroke-sub-300 {
|
||||||
|
border-color: #262626;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-kb-theme="dark"] .shadow-custom-md {
|
||||||
|
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.3), 0 2px 4px -2px rgb(0 0 0 / 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-kb-theme="dark"] .shadow-regular-xs {
|
||||||
|
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-kb-theme="dark"] .hover\:bg-bg-weak-50 {
|
||||||
|
background-color: hsl(var(--muted));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* File icon styling */
|
||||||
|
.fill-bg-white-0 {
|
||||||
|
fill: hsl(var(--background));
|
||||||
|
}
|
||||||
|
|
||||||
|
.stroke-stroke-sub-300 {
|
||||||
|
stroke: hsl(var(--border));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Separator styling */
|
||||||
|
[role="separator"] {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
[role="separator"]::before,
|
||||||
|
[role="separator"]::after {
|
||||||
|
content: '';
|
||||||
|
flex: 1;
|
||||||
|
height: 1px;
|
||||||
|
background-color: hsl(var(--border));
|
||||||
|
}
|
||||||
|
|
||||||
|
[role="separator"]::before {
|
||||||
|
margin-right: 0.625rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
[role="separator"]::after {
|
||||||
|
margin-left: 0.625rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Input group styling */
|
||||||
|
.divide-stroke-soft-200 > :not([hidden]) ~ :not([hidden]) {
|
||||||
|
border-color: hsl(var(--border));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Select none for icons */
|
||||||
|
.select-none {
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Items center for icons */
|
||||||
|
.items-center {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.justify-center {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Whitespace utilities */
|
||||||
|
.whitespace-nowrap {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-center {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Overflow utilities */
|
||||||
|
.overflow-hidden {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Flex direction */
|
||||||
|
.flex-col {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Items start */
|
||||||
|
.items-start {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Data state utilities */
|
||||||
|
.data-state-open-animate-in {
|
||||||
|
animation-name: enter;
|
||||||
|
animation-duration: 0.15s;
|
||||||
|
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-state-closed-animate-out {
|
||||||
|
animation-name: exit;
|
||||||
|
animation-duration: 0.15s;
|
||||||
|
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-state-closed-fade-out-0 {
|
||||||
|
animation-name: fadeOut;
|
||||||
|
animation-duration: 0.15s;
|
||||||
|
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-state-open-fade-in-0 {
|
||||||
|
animation-name: fadeIn;
|
||||||
|
animation-duration: 0.15s;
|
||||||
|
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-state-closed-zoom-out-95 {
|
||||||
|
animation-name: zoomOut95;
|
||||||
|
animation-duration: 0.15s;
|
||||||
|
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-state-open-zoom-in-95 {
|
||||||
|
animation-name: zoomIn95;
|
||||||
|
animation-duration: 0.15s;
|
||||||
|
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pointer events */
|
||||||
|
.pointer-events-none {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pointer-events-auto {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
@@ -0,0 +1,371 @@
|
|||||||
|
import { createSignal, For, Show } from 'solid-js';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import './FileUpload.css';
|
||||||
|
|
||||||
|
export interface FileUploadProps {
|
||||||
|
isOpen?: boolean;
|
||||||
|
onClose?: () => void;
|
||||||
|
onFilesChange?: (files: UploadedFile[]) => void;
|
||||||
|
maxFileSize?: number; // in MB
|
||||||
|
acceptedTypes?: string[];
|
||||||
|
class?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UploadedFile {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
size: number;
|
||||||
|
type: string;
|
||||||
|
status: 'uploading' | 'completed' | 'error';
|
||||||
|
progress: number;
|
||||||
|
url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultAcceptedTypes = [
|
||||||
|
'image/jpeg',
|
||||||
|
'image/png',
|
||||||
|
'application/pdf',
|
||||||
|
'video/mp4'
|
||||||
|
];
|
||||||
|
|
||||||
|
const defaultMaxFileSize = 50; // 50MB
|
||||||
|
|
||||||
|
export const FileUpload = (props: FileUploadProps) => {
|
||||||
|
const [files, setFiles] = createSignal<UploadedFile[]>([]);
|
||||||
|
const [isDragging, setIsDragging] = createSignal(false);
|
||||||
|
const [urlInput, setUrlInput] = createSignal('');
|
||||||
|
|
||||||
|
// Generate unique ID for files
|
||||||
|
const generateId = () => Math.random().toString(36).substr(2, 9);
|
||||||
|
|
||||||
|
// Format file size
|
||||||
|
const formatFileSize = (bytes: number) => {
|
||||||
|
if (bytes === 0) return '0 KB';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get file extension
|
||||||
|
const getFileExtension = (filename: string) => {
|
||||||
|
return filename.slice((filename.lastIndexOf(".") - 1 >>> 0) + 2).toUpperCase();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get file icon color based on type
|
||||||
|
const getFileTypeColor = (type: string) => {
|
||||||
|
if (type.startsWith('image/')) return 'bg-success-base';
|
||||||
|
if (type.startsWith('video/')) return 'bg-warning-base';
|
||||||
|
if (type === 'application/pdf') return 'bg-error-base';
|
||||||
|
return 'bg-neutral-base';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Validate file
|
||||||
|
const validateFile = (file: File) => {
|
||||||
|
const maxSize = (props.maxFileSize || defaultMaxFileSize) * 1024 * 1024;
|
||||||
|
const acceptedTypes = props.acceptedTypes || defaultAcceptedTypes;
|
||||||
|
|
||||||
|
if (file.size > maxSize) {
|
||||||
|
alert(`File size exceeds ${props.maxFileSize || defaultMaxFileSize}MB limit`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!acceptedTypes.includes(file.type) && !acceptedTypes.some(type => file.type.includes(type))) {
|
||||||
|
alert('File type not supported');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle file selection
|
||||||
|
const handleFileSelect = (selectedFiles: FileList | null | undefined) => {
|
||||||
|
if (!selectedFiles) return;
|
||||||
|
|
||||||
|
const newFiles: UploadedFile[] = [];
|
||||||
|
|
||||||
|
Array.from(selectedFiles).forEach(file => {
|
||||||
|
if (validateFile(file)) {
|
||||||
|
const uploadedFile: UploadedFile = {
|
||||||
|
id: generateId(),
|
||||||
|
name: file.name,
|
||||||
|
size: file.size,
|
||||||
|
type: file.type,
|
||||||
|
status: 'uploading',
|
||||||
|
progress: 0
|
||||||
|
};
|
||||||
|
newFiles.push(uploadedFile);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (newFiles.length > 0) {
|
||||||
|
const updatedFiles = [...files(), ...newFiles];
|
||||||
|
setFiles(updatedFiles);
|
||||||
|
props.onFilesChange?.(updatedFiles);
|
||||||
|
|
||||||
|
// Simulate upload progress
|
||||||
|
newFiles.forEach(file => {
|
||||||
|
simulateUpload(file.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Simulate file upload
|
||||||
|
const simulateUpload = (fileId: string) => {
|
||||||
|
let progress = 0;
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
progress += Math.random() * 30;
|
||||||
|
if (progress >= 100) {
|
||||||
|
progress = 100;
|
||||||
|
clearInterval(interval);
|
||||||
|
|
||||||
|
setFiles(prev => prev.map(file =>
|
||||||
|
file.id === fileId
|
||||||
|
? { ...file, status: 'completed', progress: 100 }
|
||||||
|
: file
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
setFiles(prev => prev.map(file =>
|
||||||
|
file.id === fileId
|
||||||
|
? { ...file, progress }
|
||||||
|
: file
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle drag events
|
||||||
|
const handleDragOver = (e: DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragging(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragLeave = (e: DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragging(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = (e: DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragging(false);
|
||||||
|
handleFileSelect(e.dataTransfer?.files);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle URL import
|
||||||
|
const handleUrlImport = () => {
|
||||||
|
const url = urlInput().trim();
|
||||||
|
if (url) {
|
||||||
|
// Extract filename from URL or use default
|
||||||
|
const filename = url.split('/').pop() || 'imported-file';
|
||||||
|
const extension = filename.includes('.') ? getFileExtension(filename) : 'PDF';
|
||||||
|
|
||||||
|
const newFile: UploadedFile = {
|
||||||
|
id: generateId(),
|
||||||
|
name: filename,
|
||||||
|
size: 0, // Unknown size for URL imports
|
||||||
|
type: extension === 'PDF' ? 'application/pdf' : 'application/octet-stream',
|
||||||
|
status: 'uploading',
|
||||||
|
progress: 0,
|
||||||
|
url
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatedFiles = [...files(), newFile];
|
||||||
|
setFiles(updatedFiles);
|
||||||
|
props.onFilesChange?.(updatedFiles);
|
||||||
|
|
||||||
|
// Simulate URL import
|
||||||
|
simulateUpload(newFile.id);
|
||||||
|
setUrlInput('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Remove file
|
||||||
|
const removeFile = (fileId: string) => {
|
||||||
|
const updatedFiles = files().filter(file => file.id !== fileId);
|
||||||
|
setFiles(updatedFiles);
|
||||||
|
props.onFilesChange?.(updatedFiles);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Close dialog
|
||||||
|
const handleClose = () => {
|
||||||
|
props.onClose?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class={cn(
|
||||||
|
"relative w-full rounded-20 bg-bg-white-0 focus:outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 max-w-[440px] shadow-custom-md",
|
||||||
|
props.class
|
||||||
|
)}
|
||||||
|
role="dialog"
|
||||||
|
aria-labelledby="file-upload-title"
|
||||||
|
aria-describedby="file-upload-description"
|
||||||
|
data-state={props.isOpen ? 'open' : 'closed'}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div class="relative flex items-start gap-3.5 py-4 pl-5 pr-14 before:absolute before:inset-x-0 before:bottom-0 before:border-b before:border-stroke-soft-200">
|
||||||
|
<div class="flex size-10 shrink-0 items-center justify-center rounded-full bg-bg-white-0 ring-1 ring-inset ring-stroke-soft-200">
|
||||||
|
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="remixicon size-5 text-text-sub-600">
|
||||||
|
<path d="M12 12.5858L16.2426 16.8284L14.8284 18.2426L13 16.415V22H11V16.413L9.17157 18.2426L7.75736 16.8284L12 12.5858ZM12 2C15.5934 2 18.5544 4.70761 18.9541 8.19395C21.2858 8.83154 23 10.9656 23 13.5C23 16.3688 20.8036 18.7246 18.0006 18.9776L18.0009 16.9644C19.6966 16.7214 21 15.2629 21 13.5C21 11.567 19.433 10 17.5 10C17.2912 10 17.0867 10.0183 16.8887 10.054C16.9616 9.7142 17 9.36158 17 9C17 6.23858 14.7614 4 12 4C9.23858 4 7 6.23858 7 9C7 9.36158 7.03838 9.7142 7.11205 10.0533C6.91331 10.0183 6.70879 10 6.5 10C4.567 10 3 11.567 3 13.5C3 15.2003 4.21241 16.6174 5.81986 16.934L6.00005 16.9646L6.00039 18.9776C3.19696 18.7252 1 16.3692 1 13.5C1 10.9656 2.71424 8.83154 5.04648 8.19411C5.44561 4.70761 8.40661 2 12 2Z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 space-y-1">
|
||||||
|
<h2 id="file-upload-title" class="text-label-sm text-text-strong-950">Upload files</h2>
|
||||||
|
<p id="file-upload-description" class="text-paragraph-xs text-text-sub-600">Select and upload the files of your choice</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Close Button */}
|
||||||
|
<button
|
||||||
|
class="flex shrink-0 items-center justify-center outline-none transition duration-200 ease-out disabled:pointer-events-none disabled:border-transparent disabled:bg-transparent disabled:text-text-disabled-300 disabled:shadow-none focus:outline-none bg-transparent text-text-sub-600 hover:bg-bg-weak-50 hover:text-text-strong-950 focus-visible:bg-bg-strong-950 focus-visible:text-text-white-0 size-6 rounded-md absolute right-4 top-4"
|
||||||
|
onClick={handleClose}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="remixicon size-5">
|
||||||
|
<path d="M11.9997 10.5865L16.9495 5.63672L18.3637 7.05093L13.4139 12.0007L18.3637 16.9504L16.9495 18.3646L11.9997 13.4149L7.04996 18.3646L5.63574 16.9504L10.5855 12.0007L5.63574 7.05093L7.04996 5.63672L11.9997 10.5865Z"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="p-5">
|
||||||
|
<div class="space-y-4">
|
||||||
|
{/* Drag & Drop Area */}
|
||||||
|
<label
|
||||||
|
class={cn(
|
||||||
|
"flex w-full cursor-pointer flex-col items-center gap-5 rounded-xl border border-dashed border-stroke-sub-300 bg-bg-white-0 p-8 text-center transition duration-200 ease-out hover:bg-bg-weak-50",
|
||||||
|
isDragging() && "border-primary-base bg-primary-alpha-10"
|
||||||
|
)}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
multiple
|
||||||
|
tabindex="-1"
|
||||||
|
class="hidden"
|
||||||
|
type="file"
|
||||||
|
accept={props.acceptedTypes?.join(',')}
|
||||||
|
onChange={(e) => handleFileSelect(e.target.files)}
|
||||||
|
/>
|
||||||
|
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="remixicon size-6 text-text-sub-600">
|
||||||
|
<path d="M12 12.5858L16.2426 16.8284L14.8284 18.2426L13 16.415V22H11V16.413L9.17157 18.2426L7.75736 16.8284L12 12.5858ZM12 2C15.5934 2 18.5544 4.70761 18.9541 8.19395C21.2858 8.83154 23 10.9656 23 13.5C23 16.3688 20.8036 18.7246 18.0006 18.9776L18.0009 16.9644C19.6966 16.7214 21 15.2629 21 13.5C21 11.567 19.433 10 17.5 10C17.2912 10 17.0867 10.0183 16.8887 10.054C16.9616 9.7142 17 9.36158 17 9C17 6.23858 14.7614 4 12 4C9.23858 4 7 6.23858 7 9C7 9.36158 7.03838 9.7142 7.11205 10.0533C6.91331 10.0183 6.70879 10 6.5 10C4.567 10 3 11.567 3 13.5C3 15.2003 4.21241 16.6174 5.81986 16.934L6.00005 16.9646L6.00039 18.9776C3.19696 18.7252 1 16.3692 1 13.5C1 10.9656 2.71424 8.83154 5.04648 8.19411C5.44561 4.70761 8.40661 2 12 2Z"></path>
|
||||||
|
</svg>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<div class="text-label-sm text-text-strong-950">Choose a file or drag & drop it here</div>
|
||||||
|
<div class="text-paragraph-xs text-text-sub-600">JPEG, PNG, PDF, and MP4 formats, up to 50 MB.</div>
|
||||||
|
</div>
|
||||||
|
<div class="inline-flex h-8 items-center justify-center gap-2.5 whitespace-nowrap rounded-lg bg-bg-white-0 px-2.5 text-label-sm text-text-sub-600 pointer-events-none ring-1 ring-inset ring-stroke-soft-200">
|
||||||
|
Browse File
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* File List */}
|
||||||
|
<Show when={files().length > 0}>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<For each={files()}>
|
||||||
|
{(file) => (
|
||||||
|
<div class="flex w-full flex-col gap-4 rounded-2xl border border-stroke-soft-200 p-4 pl-3.5">
|
||||||
|
<div class="flex gap-3">
|
||||||
|
{/* File Icon */}
|
||||||
|
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg" class="relative shrink-0 size-10">
|
||||||
|
<path d="M30 39.25H10C7.10051 39.25 4.75 36.8995 4.75 34V6C4.75 3.10051 7.10051 0.75 10 0.75H20.5147C21.9071 0.75 23.2425 1.30312 24.227 2.28769L33.7123 11.773C34.6969 12.7575 35.25 14.0929 35.25 15.4853V34C35.25 36.8995 32.8995 39.25 30 39.25Z" class="fill-bg-white-0 stroke-stroke-sub-300" stroke-width="1.5"></path>
|
||||||
|
<path d="M23 1V9C23 11.2091 24.7909 13 27 13H35" class="stroke-stroke-sub-300" stroke-width="1.5"></path>
|
||||||
|
<foreignObject x="0" y="0" width="40" height="40">
|
||||||
|
<div class={cn("absolute bottom-1.5 left-0 flex h-4 items-center rounded px-[3px] py-0.5 text-[11px] font-semibold leading-none text-static-white", getFileTypeColor(file.type))}>
|
||||||
|
{getFileExtension(file.name)}
|
||||||
|
</div>
|
||||||
|
</foreignObject>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* File Info */}
|
||||||
|
<div class="flex-1 space-y-1">
|
||||||
|
<div class="text-label-sm text-text-strong-950">{file.name}</div>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<span class="text-paragraph-xs text-text-sub-600">
|
||||||
|
{file.status === 'uploading'
|
||||||
|
? `${formatFileSize(file.size * file.progress / 100)} of ${formatFileSize(file.size)}`
|
||||||
|
: formatFileSize(file.size)
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
<span class="text-paragraph-xs text-text-sub-600">∙</span>
|
||||||
|
|
||||||
|
{/* Status Icon */}
|
||||||
|
<Show when={file.status === 'uploading'}>
|
||||||
|
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="remixicon size-4 shrink-0 animate-spin text-primary-base">
|
||||||
|
<path d="M12 2C12.5523 2 13 2.44772 13 3V6C13 6.55228 12.5523 7 12 7C11.4477 7 11 6.55228 11 6V3C11 2.44772 11.4477 2 12 2ZM12 17C12.5523 17 13 17.4477 13 18V21C13 21.5523 12.5523 22 12 22C11.4477 22 11 21.5523 11 21V18C11 17.4477 11.4477 17 12 17ZM22 12C22 12.5523 21.5523 13 21 13H18C17.4477 13 17 12.5523 17 12C17 11.4477 17.4477 11 18 11H21C21.5523 11 22 11.4477 22 12ZM7 12C7 12.5523 6.55228 13 6 13H3C2.44772 13 2 12.5523 2 12C2 11.4477 2.44772 11 3 11H6C6.55228 11 7 11.4477 7 12ZM19.0711 19.0711C18.6805 19.4616 18.0474 19.4616 17.6569 19.0711L15.5355 16.9497C15.145 16.5592 15.145 15.9261 15.5355 15.5355C15.9261 15.145 16.5592 15.145 16.9497 15.5355L19.0711 17.6569C19.4616 18.0474 19.4616 18.6805 19.0711 19.0711ZM8.46447 8.46447C8.07394 8.85499 7.44078 8.85499 7.05025 8.46447L4.92893 6.34315C4.53841 5.95262 4.53841 5.31946 4.92893 4.92893C5.31946 4.53841 5.95262 4.53841 6.34315 4.92893L8.46447 7.05025C8.85499 7.44078 8.85499 8.07394 8.46447 8.46447ZM4.92893 19.0711C4.53841 18.6805 4.53841 18.0474 4.92893 17.6569L7.05025 15.5355C7.44078 15.145 8.07394 15.145 8.46447 15.5355C8.85499 15.9261 8.85499 16.5592 8.46447 16.9497L6.34315 19.0711C5.95262 19.4616 5.31946 19.4616 4.92893 19.0711ZM15.5355 8.46447C15.145 8.07394 15.145 7.44078 15.5355 7.05025L17.6569 4.92893C18.0474 4.53841 18.6805 4.53841 19.0711 4.92893C19.4616 5.31946 19.4616 5.95262 19.0711 6.34315L16.9497 8.46447C16.5592 8.85499 15.9261 8.85499 15.5355 8.46447Z"></path>
|
||||||
|
</svg>
|
||||||
|
<span class="text-paragraph-xs text-text-strong-950">Uploading...</span>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={file.status === 'completed'}>
|
||||||
|
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="remixicon size-4 shrink-0 text-success-base">
|
||||||
|
<path d="M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM11.0026 16L18.0737 8.92893L16.6595 7.51472L11.0026 13.1716L8.17421 10.3431L6.75999 11.7574L11.0026 16Z"></path>
|
||||||
|
</svg>
|
||||||
|
<span class="text-paragraph-xs text-text-strong-950">Completed</span>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Remove Button */}
|
||||||
|
<button
|
||||||
|
class="relative flex shrink-0 items-center justify-center outline-none transition duration-200 ease-out disabled:pointer-events-none disabled:border-transparent disabled:bg-transparent disabled:text-text-disabled-300 disabled:shadow-none focus:outline-none bg-transparent text-text-sub-600 hover:bg-bg-weak-50 hover:text-text-strong-950 focus-visible:bg-bg-strong-950 focus-visible:text-text-white-0 size-5 rounded-md"
|
||||||
|
onClick={() => removeFile(file.id)}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="remixicon size-[18px]">
|
||||||
|
<path d="M11.9997 10.5865L16.9495 5.63672L18.3637 7.05093L13.4139 12.0007L18.3637 16.9504L16.9495 18.3646L11.9997 13.4149L7.04996 18.3646L5.63574 16.9504L10.5855 12.0007L5.63574 7.05093L7.04996 5.63672L11.9997 10.5865Z"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress Bar */}
|
||||||
|
<Show when={file.status === 'uploading'}>
|
||||||
|
<div class="h-1.5 w-full rounded-full bg-bg-soft-200">
|
||||||
|
<div
|
||||||
|
class="h-full rounded-full transition-all duration-300 ease-out bg-information-base"
|
||||||
|
role="progressbar"
|
||||||
|
aria-valuenow={file.progress}
|
||||||
|
aria-valuemax="100"
|
||||||
|
style={{ width: `${file.progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
{/* Separator */}
|
||||||
|
<div role="separator" class="relative flex w-full items-center gap-2.5 text-subheading-2xs text-text-soft-400 before:h-px before:w-full before:flex-1 before:bg-stroke-soft-200 after:h-px after:w-full after:flex-1 after:bg-stroke-soft-200 my-6">
|
||||||
|
OR
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* URL Import */}
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<label class="group cursor-pointer flex items-center gap-px aria-disabled:text-text-disabled-300 text-label-sm text-text-strong-950">
|
||||||
|
Import from URL Link
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="none" viewBox="0 0 20 20" class="size-5 text-text-disabled-300">
|
||||||
|
<path fill="currentColor" fill-rule="evenodd" d="M10 16.25a6.25 6.25 0 100-12.5 6.25 6.25 0 000 12.5zm1.116-3.041l.1-.408a1.709 1.709 0 01-.25.083 1.176 1.176 0 01-.308.048c-.193 0-.329-.032-.407-.095-.079-.064-.118-.184-.118-.359a3.514 3.514 0 01.118-.672l.373-1.318c.037-.121.062-.255.075-.4a3.73 3.73 0 00.02-.304.866.866 0 00-.292-.678c-.195-.174-.473-.26-.833-.26-.2 0-.412.035-.636.106a9.37 9.37 0 00-.704.256l-.1.409a3.49 3.49 0 01.262-.087c.101-.03.2-.045.297-.045.198 0 .331.034.4.1.07.066.105.185.105.354 0 .093-.01.197-.034.31a6.216 6.216 0 01-.084.36l-.374 1.325c-.033.14-.058.264-.073.374a2.42 2.42 0 00-.022.325c0 .272.1.496.301.673.201.177.483.265.846.265.236 0 .443-.03.621-.092s.417-.152.717-.27zM11.05 7.85a.772.772 0 00.26-.587.78.78 0 00-.26-.59.885.885 0 00-.628-.244.893.893 0 00-.63.244.778.778 0 00-.264.59c0 .23.088.426.263.587a.897.897 0 00.63.243.888.888 0 00.629-.243z" clip-rule="evenodd"></path>
|
||||||
|
</svg>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="group relative flex overflow-hidden bg-bg-white-0 text-text-strong-950 shadow-regular-xs transition duration-200 ease-out divide-x divide-stroke-soft-200 before:absolute before:inset-0 before:ring-1 before:ring-inset before:ring-stroke-soft-200 before:pointer-events-none before:rounded-[inherit] before:transition before:duration-200 before:ease-out hover:shadow-none has-[input:focus]:shadow-button-important-focus has-[input:focus]:before:ring-stroke-strong-950 has-[input:disabled]:shadow-none has-[input:disabled]:before:ring-transparent rounded-10 hover:[&:not(:has(input:focus)):has(>:only-child)]:before:ring-transparent w-full">
|
||||||
|
<label class="group/input-wrapper flex w-full cursor-text items-center bg-bg-white-0 transition duration-200 ease-out hover:[&:not(&:has(input:focus))]:bg-bg-weak-50 has-[input:disabled]:pointer-events-none has-[input:disabled]:bg-bg-weak-50 gap-2 px-3">
|
||||||
|
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="remixicon flex size-5 shrink-0 select-none items-center justify-center transition duration-200 ease-out group-has-[:placeholder-shown]:text-text-soft-400 text-text-sub-600 group-has-[:placeholder-shown]:group-hover/input-wrapper:text-text-sub-600 group-has-[:placeholder-shown]:group-has-[input:focus]/input-wrapper:text-text-sub-600 group-has-[input:disabled]/input-wrapper:text-text-disabled-300">
|
||||||
|
<path d="M13.0607 8.11097L14.4749 9.52518C17.2086 12.2589 17.2086 16.691 14.4749 19.4247L14.1214 19.7782C11.3877 22.5119 6.95555 22.5119 4.22188 19.7782C1.48821 17.0446 1.48821 12.6124 4.22188 9.87874L5.6361 11.293C3.68348 13.2456 3.68348 16.4114 5.6361 18.364C7.58872 20.3166 10.7545 20.3166 12.7072 18.364L13.0607 18.0105C15.0133 16.0578 15.0133 12.892 13.0607 10.9394L11.6465 9.52518L13.0607 8.11097ZM19.7782 14.1214L18.364 12.7072C20.3166 10.7545 20.3166 7.58872 18.364 5.6361C16.4114 3.68348 13.2456 3.68348 11.293 5.6361L10.9394 5.98965C8.98678 7.94227 8.98678 11.1081 10.9394 13.0607L12.3536 14.4749L10.9394 15.8891L9.52518 14.4749C6.79151 11.7413 6.79151 7.30911 9.52518 4.57544L9.87874 4.22188C12.6124 1.48821 17.0446 1.48821 19.7782 4.22188C22.5119 6.95555 22.5119 11.3877 19.7782 14.1214Z"></path>
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
class="w-full bg-transparent bg-none text-paragraph-sm text-text-strong-950 outline-none transition duration-200 ease-out placeholder:select-none placeholder:text-text-soft-400 placeholder:transition placeholder:duration-200 placeholder:ease-out group-hover/input-wrapper:placeholder:text-text-sub-600 focus:outline-none group-has-[input:focus]:placeholder:text-text-sub-600 disabled:text-text-disabled-300 disabled:placeholder:text-text-disabled-300 h-10"
|
||||||
|
placeholder="Paste file URL"
|
||||||
|
type="text"
|
||||||
|
value={urlInput()}
|
||||||
|
onInput={(e) => setUrlInput(e.target.value)}
|
||||||
|
onKeyPress={(e) => e.key === 'Enter' && handleUrlImport()}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -155,7 +155,7 @@ export const FileUploadModal = (props: FileUploadModalProps) => {
|
|||||||
return (
|
return (
|
||||||
<Show when={props.isOpen}>
|
<Show when={props.isOpen}>
|
||||||
<div
|
<div
|
||||||
class="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
class="fixed inset-0 bg-black/50 flex items-center justify-center z-50 mt-0"
|
||||||
onClick={props.onClose}
|
onClick={props.onClose}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -325,17 +325,16 @@ export const GitHubActivity = (props: GitHubActivityProps) => {
|
|||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Month labels - More visible and responsive */}
|
{/* Month labels - Show all months with responsive spacing */}
|
||||||
<div class="flex justify-between mb-3 px-8 text-sm font-medium">
|
<div class="flex justify-between mb-3 px-6 sm:px-8 text-xs sm:text-sm font-medium overflow-x-auto">
|
||||||
{getMonthLabels().map((month, index) => (
|
<div class="flex gap-2 sm:gap-3 min-w-max">
|
||||||
<span
|
{getMonthLabels().map((month) => (
|
||||||
class="text-foreground/80 hover:text-foreground transition-colors cursor-default"
|
<span class="text-foreground/80 hover:text-foreground transition-colors cursor-default whitespace-nowrap">
|
||||||
style={index % 2 === 0 ? "" : "visibility: hidden;"}
|
|
||||||
>
|
|
||||||
{month}
|
{month}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Contribution grid - Responsive and prevents overflow */}
|
{/* Contribution grid - Responsive and prevents overflow */}
|
||||||
<div class="overflow-hidden w-full">
|
<div class="overflow-hidden w-full">
|
||||||
@@ -352,7 +351,7 @@ export const GitHubActivity = (props: GitHubActivityProps) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Weekly columns - Responsive with proper overflow handling */}
|
{/* Weekly columns - Responsive with proper overflow handling */}
|
||||||
<div class="flex gap-1 overflow-x-auto overflow-y-hidden min-w-0">
|
<div class="flex gap-1 overflow-x-auto overflow-y-hidden min-w-0 pb-2">
|
||||||
{Array.from({ length: 53 }, (_, weekIndex) => (
|
{Array.from({ length: 53 }, (_, weekIndex) => (
|
||||||
<div class="flex flex-col gap-1 flex-shrink-0">
|
<div class="flex flex-col gap-1 flex-shrink-0">
|
||||||
{Array.from({ length: 7 }, (_, dayIndex) => {
|
{Array.from({ length: 7 }, (_, dayIndex) => {
|
||||||
@@ -362,7 +361,7 @@ export const GitHubActivity = (props: GitHubActivityProps) => {
|
|||||||
if (!activity) {
|
if (!activity) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
class="w-2 h-2 sm:w-3 sm:h-3 rounded-sm flex-shrink-0"
|
class="w-2.5 h-2.5 sm:w-3 sm:h-3 rounded-sm flex-shrink-0 transition-all"
|
||||||
style={`background-color: ${getActivityColor(0)}`}
|
style={`background-color: ${getActivityColor(0)}`}
|
||||||
></div>
|
></div>
|
||||||
);
|
);
|
||||||
@@ -370,7 +369,7 @@ export const GitHubActivity = (props: GitHubActivityProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
class="w-2 h-2 sm:w-3 sm:h-3 rounded-sm hover:ring-1 hover:ring-primary cursor-pointer transition-all flex-shrink-0"
|
class="w-2.5 h-2.5 sm:w-3 sm:h-3 rounded-sm hover:ring-1 hover:ring-primary cursor-pointer transition-all flex-shrink-0 hover:scale-110"
|
||||||
style={`background-color: ${getActivityColor(activity.level)}`}
|
style={`background-color: ${getActivityColor(activity.level)}`}
|
||||||
title={`${activity.date}: ${activity.count} contributions`}
|
title={`${activity.date}: ${activity.count} contributions`}
|
||||||
></div>
|
></div>
|
||||||
@@ -388,7 +387,7 @@ export const GitHubActivity = (props: GitHubActivityProps) => {
|
|||||||
<div class="flex gap-1">
|
<div class="flex gap-1">
|
||||||
{[0, 1, 2, 3, 4].map((level) => (
|
{[0, 1, 2, 3, 4].map((level) => (
|
||||||
<div
|
<div
|
||||||
class="w-2 h-2 sm:w-3 sm:h-3 rounded-sm"
|
class="w-2.5 h-2.5 sm:w-3 sm:h-3 rounded-sm"
|
||||||
style={`background-color: ${getActivityColor(level)}`}
|
style={`background-color: ${getActivityColor(level)}`}
|
||||||
></div>
|
></div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ export const LearningPathModal = (props: LearningPathModalProps) => {
|
|||||||
if (!props.isOpen) return null;
|
if (!props.isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
<div class="fixed inset-0 bg-black/50 flex items-center justify-center z-50 mt-0">
|
||||||
<div class="bg-[#1a1a1a] rounded-lg w-full max-w-2xl max-h-[90vh] overflow-y-auto mx-4 my-4">
|
<div class="bg-[#1a1a1a] rounded-lg w-full max-w-2xl max-h-[90vh] overflow-y-auto mx-4 my-4">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div class="flex items-center justify-between p-6 border-b border-[#404040]">
|
<div class="flex items-center justify-between p-6 border-b border-[#404040]">
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ export const LearningPathPreviewModal = (props: LearningPathPreviewModalProps) =
|
|||||||
if (!props.isOpen || !props.learningPath) return null;
|
if (!props.isOpen || !props.learningPath) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
<div class="fixed inset-0 bg-black/50 flex items-center justify-center z-50 mt-0">
|
||||||
<div class="bg-[#1a1a1a] rounded-lg w-full max-w-4xl max-h-[90vh] overflow-y-auto mx-4 my-4">
|
<div class="bg-[#1a1a1a] rounded-lg w-full max-w-4xl max-h-[90vh] overflow-y-auto mx-4 my-4">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export const MemberModal = (props: MemberModalProps) => {
|
|||||||
<>
|
<>
|
||||||
{/* Backdrop */}
|
{/* Backdrop */}
|
||||||
{props.isOpen && (
|
{props.isOpen && (
|
||||||
<div class="fixed inset-0 bg-black/50 z-40" onClick={props.onClose} />
|
<div class="fixed inset-0 bg-black/50 z-40 mt-0" onClick={props.onClose} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Modal */}
|
{/* Modal */}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export const NoteModal = (props: NoteModalProps) => {
|
|||||||
<>
|
<>
|
||||||
{/* Backdrop */}
|
{/* Backdrop */}
|
||||||
{props.isOpen && (
|
{props.isOpen && (
|
||||||
<div class="fixed inset-0 bg-black/50 z-40" onClick={props.onClose} />
|
<div class="fixed inset-0 bg-black/50 z-40 mt-0" onClick={props.onClose} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Modal */}
|
{/* Modal */}
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ import {
|
|||||||
IconLink,
|
IconLink,
|
||||||
IconPhoto,
|
IconPhoto,
|
||||||
IconPaperclip,
|
IconPaperclip,
|
||||||
IconEye
|
IconEye,
|
||||||
|
IconCheckbox
|
||||||
} from '@tabler/icons-solidjs';
|
} from '@tabler/icons-solidjs';
|
||||||
|
|
||||||
interface RichTextEditorProps {
|
interface RichTextEditorProps {
|
||||||
@@ -95,6 +96,8 @@ export const RichTextEditor = (props: RichTextEditorProps) => {
|
|||||||
.replace(/\*\*(.*)\*\*/gim, '<strong>$1</strong>')
|
.replace(/\*\*(.*)\*\*/gim, '<strong>$1</strong>')
|
||||||
.replace(/\*(.*)\*/gim, '<em>$1</em>')
|
.replace(/\*(.*)\*/gim, '<em>$1</em>')
|
||||||
.replace(/`(.*)`/gim, '<code>$1</code>')
|
.replace(/`(.*)`/gim, '<code>$1</code>')
|
||||||
|
.replace(/^- \[ \] (.*)$/gim, '<div class="flex items-center gap-2"><input type="checkbox" class="rounded" readonly><span>$1</span></div>')
|
||||||
|
.replace(/^- \[x\] (.*)$/gim, '<div class="flex items-center gap-2"><input type="checkbox" checked class="rounded" readonly><span>$1</span></div>')
|
||||||
.replace(/\n/gim, '<br>');
|
.replace(/\n/gim, '<br>');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,7 +109,9 @@ export const RichTextEditor = (props: RichTextEditorProps) => {
|
|||||||
return props.value
|
return props.value
|
||||||
.replace(/\n/gim, '<br>')
|
.replace(/\n/gim, '<br>')
|
||||||
.replace(/\*\*(.*?)\*\*/gim, '<strong>$1</strong>')
|
.replace(/\*\*(.*?)\*\*/gim, '<strong>$1</strong>')
|
||||||
.replace(/\*(.*?)\*/gim, '<em>$1</em>');
|
.replace(/\*(.*?)\*/gim, '<em>$1</em>')
|
||||||
|
.replace(/^- \[ \] (.*)$/gim, '<div class="flex items-center gap-2"><input type="checkbox" class="rounded" readonly><span>$1</span></div>')
|
||||||
|
.replace(/^- \[x\] (.*)$/gim, '<div class="flex items-center gap-2"><input type="checkbox" checked class="rounded" readonly><span>$1</span></div>');
|
||||||
};
|
};
|
||||||
|
|
||||||
const toolbarButtons = [
|
const toolbarButtons = [
|
||||||
@@ -117,6 +122,7 @@ export const RichTextEditor = (props: RichTextEditorProps) => {
|
|||||||
{ icon: IconHeading, action: () => insertText('## ', ''), title: 'Heading' },
|
{ icon: IconHeading, action: () => insertText('## ', ''), title: 'Heading' },
|
||||||
{ icon: IconList, action: () => insertText('- '), title: 'Bullet List' },
|
{ icon: IconList, action: () => insertText('- '), title: 'Bullet List' },
|
||||||
{ icon: IconListNumbers, action: () => insertText('1. '), title: 'Numbered List' },
|
{ icon: IconListNumbers, action: () => insertText('1. '), title: 'Numbered List' },
|
||||||
|
{ icon: IconCheckbox, action: () => insertText('- [ ] '), title: 'Checkbox' },
|
||||||
{ icon: IconQuote, action: () => insertText('> '), title: 'Quote' },
|
{ icon: IconQuote, action: () => insertText('> '), title: 'Quote' },
|
||||||
{ icon: IconCode, action: () => insertText('`', '`'), title: 'Code' },
|
{ icon: IconCode, action: () => insertText('`', '`'), title: 'Code' },
|
||||||
{ icon: IconLink, action: () => insertText('[', '](url)'), title: 'Link' },
|
{ icon: IconLink, action: () => insertText('[', '](url)'), title: 'Link' },
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createSignal, onMount, onCleanup, Show } from 'solid-js';
|
import { createSignal, Show, createMemo } from 'solid-js';
|
||||||
import {
|
import {
|
||||||
IconRefresh,
|
IconRefresh,
|
||||||
IconCheck,
|
IconCheck,
|
||||||
@@ -6,140 +6,78 @@ import {
|
|||||||
IconDownload,
|
IconDownload,
|
||||||
IconLoader2
|
IconLoader2
|
||||||
} from '@tabler/icons-solidjs';
|
} from '@tabler/icons-solidjs';
|
||||||
import { updateService, type UpdateInfo, type UpdateStatus } from '../../services/updateService';
|
import { updateStore } from '../../stores/updateStore';
|
||||||
|
|
||||||
interface UpdateCheckerProps {
|
interface UpdateCheckerProps {
|
||||||
class?: string;
|
class?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UpdateChecker(props: UpdateCheckerProps) {
|
export function UpdateChecker(props: UpdateCheckerProps) {
|
||||||
const [updateAvailable, setUpdateAvailable] = createSignal(false);
|
|
||||||
const [updateInfo, setUpdateInfo] = createSignal<UpdateInfo | null>(null);
|
|
||||||
const [updateStatus, setUpdateStatus] = createSignal<UpdateStatus>({
|
|
||||||
available: false,
|
|
||||||
downloading: false,
|
|
||||||
installing: false,
|
|
||||||
completed: false,
|
|
||||||
progress: 0
|
|
||||||
});
|
|
||||||
const [isChecking, setIsChecking] = createSignal(false);
|
|
||||||
const [showUpdateModal, setShowUpdateModal] = createSignal(false);
|
const [showUpdateModal, setShowUpdateModal] = createSignal(false);
|
||||||
const [error, setError] = createSignal<string | null>(null);
|
|
||||||
const [currentVersion, setCurrentVersion] = createSignal('1.0.0');
|
|
||||||
|
|
||||||
let pollCleanup: (() => void) | null = null;
|
// Initialize update store
|
||||||
|
updateStore.ensureInitialized();
|
||||||
|
|
||||||
const checkForUpdates = async () => {
|
const installUpdate = () => {
|
||||||
setIsChecking(true);
|
updateStore.installUpdate();
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await updateService.checkForUpdates();
|
|
||||||
setUpdateAvailable(response.updateAvailable);
|
|
||||||
setUpdateInfo(response.updateInfo || null);
|
|
||||||
setCurrentVersion(response.currentVersion);
|
|
||||||
|
|
||||||
// Save last check time
|
|
||||||
localStorage.setItem('lastUpdateCheck', Date.now().toString());
|
|
||||||
|
|
||||||
if (response.updateAvailable && response.updateInfo) {
|
|
||||||
setUpdateStatus(prev => ({ ...prev, available: true }));
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to check for updates:', err);
|
|
||||||
setError('Failed to check for updates');
|
|
||||||
} finally {
|
|
||||||
setIsChecking(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const installUpdate = async () => {
|
|
||||||
if (!updateInfo()) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
setError(null);
|
|
||||||
await updateService.installUpdate(updateInfo()!.version);
|
|
||||||
|
|
||||||
// Start polling for progress
|
|
||||||
pollCleanup = updateService.pollUpdateProgress((progress: UpdateStatus) => {
|
|
||||||
setUpdateStatus(progress);
|
|
||||||
|
|
||||||
if (progress.completed) {
|
|
||||||
setShowUpdateModal(false);
|
|
||||||
// Show success notification or trigger reload
|
|
||||||
setTimeout(() => {
|
|
||||||
window.location.reload();
|
|
||||||
}, 3000);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (progress.error) {
|
|
||||||
setError(progress.error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to install update:', err);
|
|
||||||
setError('Failed to install update');
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const cancelUpdate = () => {
|
const cancelUpdate = () => {
|
||||||
if (pollCleanup) {
|
updateStore.cancelUpdate();
|
||||||
pollCleanup();
|
|
||||||
pollCleanup = null;
|
|
||||||
}
|
|
||||||
setShowUpdateModal(false);
|
setShowUpdateModal(false);
|
||||||
setUpdateStatus({
|
|
||||||
available: updateAvailable(),
|
|
||||||
downloading: false,
|
|
||||||
installing: false,
|
|
||||||
completed: false,
|
|
||||||
progress: 0
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
onMount(() => {
|
// Create reactive computed values
|
||||||
// Set current version
|
const buttonClasses = createMemo(() => {
|
||||||
setCurrentVersion(updateService.getCurrentVersion());
|
const updateAvailable = updateStore.updateAvailable();
|
||||||
|
const updateStatus = updateStore.updateStatus();
|
||||||
|
const error = updateStore.error();
|
||||||
|
|
||||||
// Check for updates periodically (every 24 hours)
|
return {
|
||||||
const intervalId = setInterval(checkForUpdates, 24 * 60 * 60 * 1000);
|
"bg-blue-500/20 text-blue-400": updateAvailable && !updateStatus.downloading && !updateStatus.installing,
|
||||||
|
"hover:bg-blue-500/30": updateAvailable && !updateStatus.downloading && !updateStatus.installing,
|
||||||
// Check if last check was more than 24 hours ago
|
"bg-orange-500/20 text-orange-400": updateStatus.downloading || updateStatus.installing,
|
||||||
const lastCheckTime = localStorage.getItem('lastUpdateCheck');
|
"hover:bg-orange-500/30": updateStatus.downloading || updateStatus.installing,
|
||||||
const now = Date.now();
|
"bg-green-500/20 text-green-400": updateStatus.completed,
|
||||||
const twentyFourHours = 24 * 60 * 60 * 1000;
|
"hover:bg-green-500/30": updateStatus.completed,
|
||||||
|
"bg-red-500/20 text-red-400": !!error,
|
||||||
if (!lastCheckTime || (now - parseInt(lastCheckTime)) > twentyFourHours) {
|
"hover:bg-red-500/30": !!error,
|
||||||
// Check for updates on component mount if it's been more than 24 hours
|
"hover:bg-[#262626] hover:text-white text-[#a3a3a3]": !updateAvailable && !updateStatus.downloading && !updateStatus.installing && !updateStatus.completed && !error
|
||||||
checkForUpdates();
|
};
|
||||||
localStorage.setItem('lastUpdateCheck', now.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
onCleanup(() => {
|
|
||||||
clearInterval(intervalId);
|
|
||||||
if (pollCleanup) {
|
|
||||||
pollCleanup();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isDisabled = createMemo(() => {
|
||||||
|
const isChecking = updateStore.isChecking();
|
||||||
|
const updateStatus = updateStore.updateStatus();
|
||||||
|
return isChecking || updateStatus.downloading || updateStatus.installing;
|
||||||
});
|
});
|
||||||
|
|
||||||
const getStatusIcon = () => {
|
const getStatusIcon = () => {
|
||||||
if (isChecking()) return <IconLoader2 class="size-4 animate-spin" />;
|
const isChecking = updateStore.isChecking();
|
||||||
if (updateStatus().downloading || updateStatus().installing) return <IconLoader2 class="size-4 animate-spin" />;
|
const updateStatus = updateStore.updateStatus();
|
||||||
if (updateStatus().completed) return <IconCheck class="size-4 text-green-500" />;
|
const updateAvailable = updateStore.updateAvailable();
|
||||||
if (updateAvailable()) return <IconDownload class="size-4 text-blue-500" />;
|
const error = updateStore.error();
|
||||||
if (error()) return <IconAlertTriangle class="size-4 text-red-500" />;
|
|
||||||
|
if (isChecking) return <IconLoader2 class="size-4 animate-spin" />;
|
||||||
|
if (updateStatus.downloading || updateStatus.installing) return <IconLoader2 class="size-4 animate-spin" />;
|
||||||
|
if (updateStatus.completed) return <IconCheck class="size-4 text-green-500" />;
|
||||||
|
if (updateAvailable) return <IconDownload class="size-4 text-blue-500" />;
|
||||||
|
if (error) return <IconAlertTriangle class="size-4 text-red-500" />;
|
||||||
return <IconRefresh class="size-4" />;
|
return <IconRefresh class="size-4" />;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStatusText = () => {
|
const getStatusText = () => {
|
||||||
if (isChecking()) return 'Checking...';
|
const isChecking = updateStore.isChecking();
|
||||||
if (updateStatus().downloading) return `Downloading... ${Math.round(updateStatus().progress)}%`;
|
const updateStatus = updateStore.updateStatus();
|
||||||
if (updateStatus().installing) return `Installing... ${Math.round(updateStatus().progress)}%`;
|
const updateAvailable = updateStore.updateAvailable();
|
||||||
if (updateStatus().completed) return 'Update Complete';
|
const error = updateStore.error();
|
||||||
if (updateAvailable()) return 'Update Available';
|
|
||||||
if (error()) return 'Update Failed';
|
if (isChecking) return 'Checking...';
|
||||||
|
if (updateStatus.downloading) return `Downloading... ${Math.round(updateStatus.progress)}%`;
|
||||||
|
if (updateStatus.installing) return `Installing... ${Math.round(updateStatus.progress)}%`;
|
||||||
|
if (updateStatus.completed) return 'Update Complete';
|
||||||
|
if (updateAvailable) return 'Update Available';
|
||||||
|
if (error) return 'Update Failed';
|
||||||
return 'Check Updates';
|
return 'Check Updates';
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -148,25 +86,22 @@ export function UpdateChecker(props: UpdateCheckerProps) {
|
|||||||
<div class={`flex flex-col gap-2 ${props.class || ''}`}>
|
<div class={`flex flex-col gap-2 ${props.class || ''}`}>
|
||||||
{/* Current Version Display */}
|
{/* Current Version Display */}
|
||||||
<div class="text-xs text-muted-foreground px-2 text-center">
|
<div class="text-xs text-muted-foreground px-2 text-center">
|
||||||
Version {currentVersion()}
|
Version {updateStore.currentVersion()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Check Updates Button */}
|
{/* Check Updates Button */}
|
||||||
<button
|
<button
|
||||||
onClick={() => updateAvailable() ? setShowUpdateModal(true) : checkForUpdates()}
|
onClick={() => {
|
||||||
class="group inline-flex rounded-md text-sm font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 h-9 px-4 py-2 justify-start items-center gap-2 truncate relative overflow-hidden w-full"
|
const updateAvailable = updateStore.updateAvailable();
|
||||||
classList={{
|
if (updateAvailable) {
|
||||||
"bg-blue-500/20 text-blue-400": updateAvailable() && !updateStatus().downloading && !updateStatus().installing,
|
setShowUpdateModal(true);
|
||||||
"hover:bg-blue-500/30": updateAvailable() && !updateStatus().downloading && !updateStatus().installing,
|
} else {
|
||||||
"bg-orange-500/20 text-orange-400": updateStatus().downloading || updateStatus().installing,
|
updateStore.checkForUpdates();
|
||||||
"hover:bg-orange-500/30": updateStatus().downloading || updateStatus().installing,
|
}
|
||||||
"bg-green-500/20 text-green-400": updateStatus().completed,
|
|
||||||
"hover:bg-green-500/30": updateStatus().completed,
|
|
||||||
"bg-red-500/20 text-red-400": !!error(),
|
|
||||||
"hover:bg-red-500/30": !!error(),
|
|
||||||
"hover:bg-[#262626] hover:text-white text-[#a3a3a3]": !updateAvailable() && !updateStatus().downloading && !updateStatus().installing && !updateStatus().completed && !error()
|
|
||||||
}}
|
}}
|
||||||
disabled={isChecking() || updateStatus().downloading || updateStatus().installing}
|
class="group inline-flex rounded-md text-sm font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 h-9 px-4 py-2 justify-start items-center gap-2 truncate relative overflow-hidden w-full"
|
||||||
|
classList={buttonClasses()}
|
||||||
|
disabled={isDisabled()}
|
||||||
>
|
>
|
||||||
<div class="relative z-10 flex items-center gap-2">
|
<div class="relative z-10 flex items-center gap-2">
|
||||||
{getStatusIcon()}
|
{getStatusIcon()}
|
||||||
@@ -179,8 +114,8 @@ export function UpdateChecker(props: UpdateCheckerProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Update Modal */}
|
{/* Update Modal */}
|
||||||
<Show when={showUpdateModal() && updateInfo()}>
|
<Show when={showUpdateModal() && updateStore.updateInfo()}>
|
||||||
<div class="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
<div class="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4 mt-0">
|
||||||
<div class="bg-card border border-border rounded-lg shadow-lg max-w-md w-full max-h-[80vh] overflow-auto">
|
<div class="bg-card border border-border rounded-lg shadow-lg max-w-md w-full max-h-[80vh] overflow-auto">
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<div class="flex items-center gap-3 mb-4">
|
<div class="flex items-center gap-3 mb-4">
|
||||||
@@ -192,53 +127,59 @@ export function UpdateChecker(props: UpdateCheckerProps) {
|
|||||||
<div>
|
<div>
|
||||||
<div class="flex justify-between items-center mb-2">
|
<div class="flex justify-between items-center mb-2">
|
||||||
<span class="text-sm text-muted-foreground">Current Version</span>
|
<span class="text-sm text-muted-foreground">Current Version</span>
|
||||||
<span class="text-sm font-medium">{currentVersion()}</span>
|
<span class="text-sm font-medium">{updateStore.currentVersion()}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between items-center">
|
<div class="flex justify-between items-center">
|
||||||
<span class="text-sm text-muted-foreground">Latest Version</span>
|
<span class="text-sm text-muted-foreground">Latest Version</span>
|
||||||
<span class="text-sm font-medium text-blue-500">{updateInfo()!.version}</span>
|
<span class="text-sm font-medium text-blue-500">{updateStore.updateInfo()!.version}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-sm font-medium mb-2">Release Notes</h3>
|
<h3 class="text-sm font-medium mb-2">Release Notes</h3>
|
||||||
<div class="text-sm text-muted-foreground whitespace-pre-line bg-muted/30 rounded p-3">
|
<div class="text-sm text-muted-foreground whitespace-pre-line bg-muted/30 rounded p-3">
|
||||||
{updateInfo()!.releaseNotes}
|
{updateStore.updateInfo()!.releaseNotes}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-between items-center text-sm">
|
<div class="flex justify-between items-center text-sm">
|
||||||
<span class="text-muted-foreground">Download Size</span>
|
<span class="text-muted-foreground">Download Size</span>
|
||||||
<span>{updateInfo()!.size}</span>
|
<span>{updateStore.updateInfo()!.size}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={updateStatus().downloading || updateStatus().installing}>
|
<Show when={(() => {
|
||||||
|
const updateStatus = updateStore.updateStatus();
|
||||||
|
return updateStatus.downloading || updateStatus.installing;
|
||||||
|
})()}>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<div class="flex justify-between text-sm">
|
<div class="flex justify-between text-sm">
|
||||||
<span class="text-muted-foreground">
|
<span class="text-muted-foreground">
|
||||||
{updateStatus().downloading ? 'Downloading' : 'Installing'}
|
{(() => {
|
||||||
|
const updateStatus = updateStore.updateStatus();
|
||||||
|
return updateStatus.downloading ? 'Downloading' : 'Installing';
|
||||||
|
})()}
|
||||||
</span>
|
</span>
|
||||||
<span>{Math.round(updateStatus().progress)}%</span>
|
<span>{(() => Math.round(updateStore.updateStatus().progress))()}%</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full bg-muted rounded-full h-2">
|
<div class="w-full bg-muted rounded-full h-2">
|
||||||
<div
|
<div
|
||||||
class="bg-blue-500 h-2 rounded-full transition-all duration-300"
|
class="bg-blue-500 h-2 rounded-full transition-all duration-300"
|
||||||
style={{ width: `${updateStatus().progress}%` }}
|
style={{ width: `${updateStore.updateStatus().progress}%` }}
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={error()}>
|
<Show when={updateStore.error()}>
|
||||||
<div class="bg-red-500/10 border border-red-500/20 rounded p-3">
|
<div class="bg-red-500/10 border border-red-500/20 rounded p-3">
|
||||||
<div class="flex items-center gap-2 text-red-500 text-sm">
|
<div class="flex items-center gap-2 text-red-500 text-sm">
|
||||||
<IconAlertTriangle class="size-4" />
|
<IconAlertTriangle class="size-4" />
|
||||||
<span>{error()}</span>
|
<span>{updateStore.error()}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={updateStatus().completed}>
|
<Show when={(() => updateStore.updateStatus().completed)}>
|
||||||
<div class="bg-green-500/10 border border-green-500/20 rounded p-3">
|
<div class="bg-green-500/10 border border-green-500/20 rounded p-3">
|
||||||
<div class="flex items-center gap-2 text-green-500 text-sm">
|
<div class="flex items-center gap-2 text-green-500 text-sm">
|
||||||
<IconCheck class="size-4" />
|
<IconCheck class="size-4" />
|
||||||
@@ -249,7 +190,10 @@ export function UpdateChecker(props: UpdateCheckerProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-3 mt-6">
|
<div class="flex gap-3 mt-6">
|
||||||
<Show when={!updateStatus().downloading && !updateStatus().installing && !updateStatus().completed}>
|
<Show when={(() => {
|
||||||
|
const updateStatus = updateStore.updateStatus();
|
||||||
|
return !updateStatus.downloading && !updateStatus.installing && !updateStatus.completed;
|
||||||
|
})()}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowUpdateModal(false)}
|
onClick={() => setShowUpdateModal(false)}
|
||||||
class="flex-1 px-4 py-2 text-sm border border-border rounded-md hover:bg-muted transition-colors"
|
class="flex-1 px-4 py-2 text-sm border border-border rounded-md hover:bg-muted transition-colors"
|
||||||
@@ -258,17 +202,30 @@ export function UpdateChecker(props: UpdateCheckerProps) {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={installUpdate}
|
onClick={installUpdate}
|
||||||
disabled={updateStatus().downloading || updateStatus().installing}
|
disabled={(() => {
|
||||||
|
const updateStatus = updateStore.updateStatus();
|
||||||
|
return updateStatus.downloading || updateStatus.installing;
|
||||||
|
})()}
|
||||||
class="flex-1 px-4 py-2 text-sm bg-blue-500 text-white rounded-md hover:bg-blue-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
class="flex-1 px-4 py-2 text-sm bg-blue-500 text-white rounded-md hover:bg-blue-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||||
>
|
>
|
||||||
<Show when={updateStatus().downloading || updateStatus().installing}>
|
<Show when={(() => {
|
||||||
|
const updateStatus = updateStore.updateStatus();
|
||||||
|
return updateStatus.downloading || updateStatus.installing;
|
||||||
|
})()}>
|
||||||
<IconLoader2 class="size-4 animate-spin" />
|
<IconLoader2 class="size-4 animate-spin" />
|
||||||
</Show>
|
</Show>
|
||||||
{updateStatus().downloading || updateStatus().installing ? 'Installing...' : 'Install Update'}
|
{(() => {
|
||||||
|
const updateStatus = updateStore.updateStatus();
|
||||||
|
return updateStatus.downloading || updateStatus.installing ? 'Installing...' : 'Install Update';
|
||||||
|
})()}
|
||||||
</button>
|
</button>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={updateStatus().downloading || updateStatus().installing || error()}>
|
<Show when={(() => {
|
||||||
|
const updateStatus = updateStore.updateStatus();
|
||||||
|
const error = updateStore.error();
|
||||||
|
return updateStatus.downloading || updateStatus.installing || error;
|
||||||
|
})()}>
|
||||||
<button
|
<button
|
||||||
onClick={cancelUpdate}
|
onClick={cancelUpdate}
|
||||||
class="px-4 py-2 text-sm border border-border rounded-md hover:bg-muted transition-colors"
|
class="px-4 py-2 text-sm border border-border rounded-md hover:bg-muted transition-colors"
|
||||||
@@ -277,7 +234,7 @@ export function UpdateChecker(props: UpdateCheckerProps) {
|
|||||||
</button>
|
</button>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={updateStatus().completed}>
|
<Show when={(() => updateStore.updateStatus().completed)}>
|
||||||
<button
|
<button
|
||||||
onClick={() => window.location.reload()}
|
onClick={() => window.location.reload()}
|
||||||
class="w-full px-4 py-2 text-sm bg-green-500 text-white rounded-md hover:bg-green-600 transition-colors"
|
class="w-full px-4 py-2 text-sm bg-green-500 text-white rounded-md hover:bg-green-600 transition-colors"
|
||||||
|
|||||||
@@ -1,299 +0,0 @@
|
|||||||
import { createSignal, onMount, Show, For } from 'solid-js'
|
|
||||||
import {
|
|
||||||
IconDownload,
|
|
||||||
IconRefresh,
|
|
||||||
IconX,
|
|
||||||
IconCheck,
|
|
||||||
IconAlertTriangle,
|
|
||||||
IconLoader2
|
|
||||||
} from '@tabler/icons-solidjs'
|
|
||||||
|
|
||||||
interface UpdateInfo {
|
|
||||||
version: string
|
|
||||||
releaseNotes: string
|
|
||||||
downloadUrl: string
|
|
||||||
mandatory: boolean
|
|
||||||
size: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UpdateStatus {
|
|
||||||
available: boolean
|
|
||||||
downloading: boolean
|
|
||||||
installing: boolean
|
|
||||||
completed: boolean
|
|
||||||
error: string | null
|
|
||||||
progress: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export function UpdateNotification() {
|
|
||||||
const [updateInfo, setUpdateInfo] = createSignal<UpdateInfo | null>(null)
|
|
||||||
const [updateStatus, setUpdateStatus] = createSignal<UpdateStatus>({
|
|
||||||
available: false,
|
|
||||||
downloading: false,
|
|
||||||
installing: false,
|
|
||||||
completed: false,
|
|
||||||
error: null,
|
|
||||||
progress: 0
|
|
||||||
})
|
|
||||||
const [dismissed, setDismissed] = createSignal(false)
|
|
||||||
const [expanded, setExpanded] = createSignal(false)
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
checkForUpdates()
|
|
||||||
// Check for updates every 30 minutes
|
|
||||||
const interval = setInterval(checkForUpdates, 30 * 60 * 1000)
|
|
||||||
return () => clearInterval(interval)
|
|
||||||
})
|
|
||||||
|
|
||||||
const checkForUpdates = async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/updates/check')
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json()
|
|
||||||
if (data.updateAvailable) {
|
|
||||||
setUpdateInfo(data.updateInfo)
|
|
||||||
setUpdateStatus(prev => ({ ...prev, available: true }))
|
|
||||||
setDismissed(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to check for updates:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const startUpdate = async () => {
|
|
||||||
if (!updateInfo()) return
|
|
||||||
|
|
||||||
setUpdateStatus(prev => ({
|
|
||||||
...prev,
|
|
||||||
downloading: true,
|
|
||||||
error: null,
|
|
||||||
progress: 0
|
|
||||||
}))
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Start the update process
|
|
||||||
const response = await fetch('/api/updates/install', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ version: updateInfo()?.version })
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Failed to start update')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Monitor progress
|
|
||||||
monitorUpdateProgress()
|
|
||||||
} catch (error) {
|
|
||||||
setUpdateStatus(prev => ({
|
|
||||||
...prev,
|
|
||||||
downloading: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Update failed'
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const monitorUpdateProgress = async () => {
|
|
||||||
const progressInterval = setInterval(async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/updates/progress')
|
|
||||||
if (response.ok) {
|
|
||||||
const progress = await response.json()
|
|
||||||
|
|
||||||
setUpdateStatus(prev => ({
|
|
||||||
...prev,
|
|
||||||
progress: progress.progress,
|
|
||||||
installing: progress.installing,
|
|
||||||
downloading: progress.downloading
|
|
||||||
}))
|
|
||||||
|
|
||||||
if (progress.completed) {
|
|
||||||
clearInterval(progressInterval)
|
|
||||||
setUpdateStatus(prev => ({ ...prev, completed: true }))
|
|
||||||
|
|
||||||
// Reload page after a short delay to show completion
|
|
||||||
setTimeout(() => {
|
|
||||||
window.location.reload()
|
|
||||||
}, 2000)
|
|
||||||
} else if (progress.error) {
|
|
||||||
clearInterval(progressInterval)
|
|
||||||
setUpdateStatus(prev => ({
|
|
||||||
...prev,
|
|
||||||
downloading: false,
|
|
||||||
installing: false,
|
|
||||||
error: progress.error
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
clearInterval(progressInterval)
|
|
||||||
setUpdateStatus(prev => ({
|
|
||||||
...prev,
|
|
||||||
downloading: false,
|
|
||||||
installing: false,
|
|
||||||
error: 'Failed to monitor update progress'
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
}, 1000)
|
|
||||||
}
|
|
||||||
|
|
||||||
const dismiss = () => {
|
|
||||||
setDismissed(true)
|
|
||||||
if (!updateInfo()?.mandatory) {
|
|
||||||
setUpdateStatus(prev => ({ ...prev, available: false }))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const status = updateStatus()
|
|
||||||
const info = updateInfo()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Show when={status.available && !dismissed()}>
|
|
||||||
<div class="border-b border-border bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-950/20 dark:to-indigo-950/20">
|
|
||||||
<div class="px-4 py-3">
|
|
||||||
<div class="flex items-start gap-3">
|
|
||||||
{/* Icon */}
|
|
||||||
<div class="flex-shrink-0 mt-0.5">
|
|
||||||
<Show
|
|
||||||
when={status.completed}
|
|
||||||
fallback={
|
|
||||||
<Show
|
|
||||||
when={status.error}
|
|
||||||
fallback={
|
|
||||||
<Show
|
|
||||||
when={status.downloading || status.installing}
|
|
||||||
fallback={<IconDownload class="size-5 text-blue-600 dark:text-blue-400 animate-pulse" />}
|
|
||||||
>
|
|
||||||
<IconLoader2 class="size-5 text-blue-600 dark:text-blue-400 animate-spin" />
|
|
||||||
</Show>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<IconAlertTriangle class="size-5 text-red-600 dark:text-red-400" />
|
|
||||||
</Show>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<IconCheck class="size-5 text-green-600 dark:text-green-400" />
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
<div class="flex items-center justify-between gap-2">
|
|
||||||
<div>
|
|
||||||
<Show
|
|
||||||
when={status.completed}
|
|
||||||
fallback={
|
|
||||||
<Show
|
|
||||||
when={status.error}
|
|
||||||
fallback={
|
|
||||||
<p class="text-sm font-medium text-blue-900 dark:text-blue-100">
|
|
||||||
New version {info?.version} available
|
|
||||||
</p>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<p class="text-sm font-medium text-red-900 dark:text-red-100">
|
|
||||||
Update failed
|
|
||||||
</p>
|
|
||||||
</Show>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<p class="text-sm font-medium text-green-900 dark:text-green-100">
|
|
||||||
Update completed! Reloading...
|
|
||||||
</p>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={!status.completed && !status.error}>
|
|
||||||
<p class="text-xs text-blue-700 dark:text-blue-300 mt-1">
|
|
||||||
{info?.size} • {info?.mandatory ? 'Required update' : 'Optional update'}
|
|
||||||
</p>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div class="flex items-center gap-2 flex-shrink-0">
|
|
||||||
<Show when={!status.completed && !status.error && !status.downloading}>
|
|
||||||
<button
|
|
||||||
onClick={() => setExpanded(!expanded())}
|
|
||||||
class="text-xs text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-200"
|
|
||||||
>
|
|
||||||
{expanded() ? 'Hide' : 'Details'}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={startUpdate}
|
|
||||||
class="inline-flex items-center gap-1 px-3 py-1 text-xs font-medium bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
|
|
||||||
>
|
|
||||||
<IconDownload class="size-3" />
|
|
||||||
Update Now
|
|
||||||
</button>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={status.downloading || status.installing}>
|
|
||||||
<div class="text-xs text-blue-600 dark:text-blue-400">
|
|
||||||
{status.installing ? 'Installing...' : `Downloading... ${Math.round(status.progress)}%`}
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={status.error}>
|
|
||||||
<button
|
|
||||||
onClick={startUpdate}
|
|
||||||
class="inline-flex items-center gap-1 px-3 py-1 text-xs font-medium bg-red-600 text-white rounded-md hover:bg-red-700 transition-colors"
|
|
||||||
>
|
|
||||||
<IconRefresh class="size-3" />
|
|
||||||
Retry
|
|
||||||
</button>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={!info?.mandatory}>
|
|
||||||
<button
|
|
||||||
onClick={dismiss}
|
|
||||||
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
|
||||||
>
|
|
||||||
<IconX class="size-4" />
|
|
||||||
</button>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Progress Bar */}
|
|
||||||
<Show when={status.downloading || status.installing}>
|
|
||||||
<div class="mt-2">
|
|
||||||
<div class="w-full bg-blue-100 dark:bg-blue-900/30 rounded-full h-1.5">
|
|
||||||
<div
|
|
||||||
class="bg-blue-600 h-1.5 rounded-full transition-all duration-300 ease-out"
|
|
||||||
style={{ width: `${status.progress}%` }}
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
{/* Expanded Details */}
|
|
||||||
<Show when={expanded() && info}>
|
|
||||||
<div class="mt-3 p-3 bg-white/50 dark:bg-black/20 rounded-md border border-blue-200 dark:border-blue-800">
|
|
||||||
<h4 class="text-sm font-medium text-blue-900 dark:text-blue-100 mb-2">
|
|
||||||
What's new in {info?.version}
|
|
||||||
</h4>
|
|
||||||
<div class="text-xs text-blue-800 dark:text-blue-200 space-y-1">
|
|
||||||
<For each={info?.releaseNotes.split('\n').filter(line => line.trim()) || []}>
|
|
||||||
{(line) => <p>• {line}</p>}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
{/* Error Message */}
|
|
||||||
<Show when={status.error}>
|
|
||||||
<div class="mt-2 p-2 bg-red-50 dark:bg-red-950/20 rounded-md border border-red-200 dark:border-red-800">
|
|
||||||
<p class="text-xs text-red-800 dark:text-red-200">
|
|
||||||
{status.error}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -89,13 +89,13 @@ export const UploadModal = (props: UploadModalProps) => {
|
|||||||
<>
|
<>
|
||||||
{/* Backdrop */}
|
{/* Backdrop */}
|
||||||
{props.isOpen && (
|
{props.isOpen && (
|
||||||
<div class="fixed inset-0 bg-black/50 z-40" onClick={props.onClose} />
|
<div class="fixed inset-0 bg-black/50 z-[60] mt-0" onClick={props.onClose} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Modal */}
|
{/* Modal */}
|
||||||
<div class={`fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-card border border-border rounded-lg shadow-xl transition-all duration-300 z-50 ${
|
<div class={`fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-card border border-border rounded-lg shadow-xl transition-all duration-300 z-[70] ${
|
||||||
props.isOpen ? 'opacity-100 scale-100' : 'opacity-0 scale-95 pointer-events-none'
|
props.isOpen ? 'opacity-100 scale-100' : 'opacity-0 scale-95 pointer-events-none'
|
||||||
}`} style="width: 600px; max-width: 90vw; max-height: 80vh; overflow-y: auto;">
|
}`} style="width: min(600px, 90vw); max-height: min(80vh, 600px); overflow-y: auto;">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div class="flex items-center justify-between p-6 border-b border-border">
|
<div class="flex items-center justify-between p-6 border-b border-border">
|
||||||
<h3 class="text-lg font-semibold">Import Documents</h3>
|
<h3 class="text-lg font-semibold">Import Documents</h3>
|
||||||
@@ -108,10 +108,10 @@ export const UploadModal = (props: UploadModalProps) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div class="p-6 space-y-4">
|
<div class="p-4 sm:p-6 space-y-4">
|
||||||
{/* Drop Zone */}
|
{/* Drop Zone */}
|
||||||
<div
|
<div
|
||||||
class={`border-2 border-dashed rounded-lg p-8 text-center transition-colors ${
|
class={`border-2 border-dashed rounded-lg p-4 sm:p-8 text-center transition-colors ${
|
||||||
isDragging()
|
isDragging()
|
||||||
? 'border-primary bg-primary/5'
|
? 'border-primary bg-primary/5'
|
||||||
: 'border-border hover:border-muted-foreground'
|
: 'border-border hover:border-muted-foreground'
|
||||||
@@ -120,9 +120,9 @@ export const UploadModal = (props: UploadModalProps) => {
|
|||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
>
|
>
|
||||||
<IconUpload class="size-12 mx-auto mb-4 text-muted-foreground" />
|
<IconUpload class="size-8 sm:size-12 mx-auto mb-4 text-muted-foreground" />
|
||||||
<h4 class="text-lg font-medium mb-2">Drop files here</h4>
|
<h4 class="text-base sm:text-lg font-medium mb-2">Drop files here</h4>
|
||||||
<p class="text-muted-foreground mb-4">or click to browse</p>
|
<p class="text-muted-foreground mb-4 text-sm sm:text-base">or click to browse</p>
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
multiple
|
multiple
|
||||||
@@ -164,7 +164,7 @@ export const UploadModal = (props: UploadModalProps) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div class="flex justify-end gap-2 p-6 border-t border-border">
|
<div class="flex flex-col sm:flex-row justify-end gap-2 p-4 sm:p-6 border-t border-border">
|
||||||
<Button variant="outline" onClick={props.onClose}>
|
<Button variant="outline" onClick={props.onClose}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export const VideoPreviewModal = (props: VideoPreviewModalProps) => {
|
|||||||
<>
|
<>
|
||||||
{/* Backdrop */}
|
{/* Backdrop */}
|
||||||
{props.isOpen && (
|
{props.isOpen && (
|
||||||
<div class="fixed inset-0 bg-black/50 z-40" onClick={props.onClose} />
|
<div class="fixed inset-0 bg-black/50 z-40 mt-0" onClick={props.onClose} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Modal */}
|
{/* Modal */}
|
||||||
|
|||||||
@@ -49,15 +49,15 @@ export const VideoUploadModal = (props: VideoUploadModalProps) => {
|
|||||||
<>
|
<>
|
||||||
{/* Backdrop */}
|
{/* Backdrop */}
|
||||||
{props.isOpen && (
|
{props.isOpen && (
|
||||||
<div class="fixed inset-0 bg-black/50 z-40" onClick={props.onClose} />
|
<div class="fixed inset-0 bg-black/50 z-[60] mt-0" onClick={props.onClose} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Modal */}
|
{/* Modal */}
|
||||||
<div class={`fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-card border border-border rounded-lg shadow-xl transition-all duration-300 z-50 ${
|
<div class={`fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-card border border-border rounded-lg shadow-xl transition-all duration-300 z-[70] ${
|
||||||
props.isOpen ? 'opacity-100 scale-100' : 'opacity-0 scale-95 pointer-events-none'
|
props.isOpen ? 'opacity-100 scale-100' : 'opacity-0 scale-95 pointer-events-none'
|
||||||
}`} style="width: 500px; max-width: 90vw;">
|
}`} style="width: min(500px, 90vw); max-height: min(80vh, 600px); overflow-y: auto;">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div class="flex items-center justify-between p-6 border-b border-border">
|
<div class="flex items-center justify-between p-4 sm:p-6 border-b border-border">
|
||||||
<h3 class="text-lg font-semibold">Add YouTube Video</h3>
|
<h3 class="text-lg font-semibold">Add YouTube Video</h3>
|
||||||
<button
|
<button
|
||||||
onClick={props.onClose}
|
onClick={props.onClose}
|
||||||
@@ -68,7 +68,7 @@ export const VideoUploadModal = (props: VideoUploadModalProps) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div class="p-6 space-y-4">
|
<div class="p-4 sm:p-6 space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="text-sm font-medium">YouTube URL</label>
|
<label class="text-sm font-medium">YouTube URL</label>
|
||||||
<Input
|
<Input
|
||||||
@@ -108,7 +108,7 @@ export const VideoUploadModal = (props: VideoUploadModalProps) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div class="flex justify-end gap-2 p-6 border-t border-border">
|
<div class="flex flex-col sm:flex-row justify-end gap-2 p-4 sm:p-6 border-t border-border">
|
||||||
<Button variant="outline" onClick={props.onClose}>
|
<Button variant="outline" onClick={props.onClose}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { For, Show } from 'solid-js';
|
import { For, Show, createEffect } from 'solid-js';
|
||||||
import { IconX, IconEdit, IconPin, IconTrash, IconCopy, IconDownload, IconPaperclip } from '@tabler/icons-solidjs';
|
import { IconX, IconEdit, IconPin, IconTrash, IconCopy, IconDownload, IconPaperclip } from '@tabler/icons-solidjs';
|
||||||
|
|
||||||
interface Note {
|
interface Note {
|
||||||
@@ -30,11 +30,40 @@ interface ViewNoteModalProps {
|
|||||||
onDelete: (noteId: number) => void;
|
onDelete: (noteId: number) => void;
|
||||||
onCopyContent?: (note: Note) => void;
|
onCopyContent?: (note: Note) => void;
|
||||||
onExportNote?: (note: Note) => void;
|
onExportNote?: (note: Note) => void;
|
||||||
|
onUpdateNote?: (noteId: number, content: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ViewNoteModal = (props: ViewNoteModalProps) => {
|
export const ViewNoteModal = (props: ViewNoteModalProps) => {
|
||||||
console.log('ViewNoteModal render:', { isOpen: props.isOpen, note: props.note?.title });
|
console.log('ViewNoteModal render:', { isOpen: props.isOpen, note: props.note?.title });
|
||||||
|
|
||||||
|
// Make the function available globally for checkbox onchange handlers
|
||||||
|
createEffect(() => {
|
||||||
|
(window as any).updateViewNoteContent = (checkbox: HTMLInputElement) => {
|
||||||
|
if (props.note && props.onUpdateNote) {
|
||||||
|
const lines = props.note.content.split('\n');
|
||||||
|
let checkboxCount = 0;
|
||||||
|
const checkboxElements = document.querySelectorAll('.note-content input[type="checkbox"]');
|
||||||
|
const checkboxIndex = Array.from(checkboxElements).indexOf(checkbox);
|
||||||
|
|
||||||
|
const updatedLines = lines.map(line => {
|
||||||
|
const uncheckedMatch = line.match(/^- \[ \] (.*)$/);
|
||||||
|
const checkedMatch = line.match(/^- \[x\] (.*)$/);
|
||||||
|
|
||||||
|
if (uncheckedMatch || checkedMatch) {
|
||||||
|
if (checkboxCount === checkboxIndex) {
|
||||||
|
const text = uncheckedMatch ? uncheckedMatch[1] : (checkedMatch ? checkedMatch[1] : '');
|
||||||
|
return checkbox.checked ? `- [x] ${text}` : `- [ ] ${text}`;
|
||||||
|
}
|
||||||
|
checkboxCount++;
|
||||||
|
}
|
||||||
|
return line;
|
||||||
|
});
|
||||||
|
|
||||||
|
props.onUpdateNote(props.note.id, updatedLines.join('\n'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Backdrop */}
|
{/* Backdrop */}
|
||||||
@@ -154,7 +183,7 @@ export const ViewNoteModal = (props: ViewNoteModalProps) => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Note Content */}
|
{/* Note Content */}
|
||||||
<div class="prose prose-invert max-w-none">
|
<div class="prose prose-invert max-w-none note-content">
|
||||||
{props.note.isHtml ? (
|
{props.note.isHtml ? (
|
||||||
<div
|
<div
|
||||||
class="text-[#fafafa] leading-relaxed"
|
class="text-[#fafafa] leading-relaxed"
|
||||||
@@ -172,6 +201,8 @@ export const ViewNoteModal = (props: ViewNoteModalProps) => {
|
|||||||
.replace(/\*(.*?)\*/g, '<em class="italic">$1</em>')
|
.replace(/\*(.*?)\*/g, '<em class="italic">$1</em>')
|
||||||
.replace(/`(.*?)`/g, '<code class="bg-[#262626] px-1 py-0.5 rounded text-sm">$1</code>')
|
.replace(/`(.*?)`/g, '<code class="bg-[#262626] px-1 py-0.5 rounded text-sm">$1</code>')
|
||||||
.replace(/```(.*?)\n([\s\S]*?)```/g, '<pre class="bg-[#262626] p-4 rounded mb-4 overflow-x-auto"><code class="text-sm">$2</code></pre>')
|
.replace(/```(.*?)\n([\s\S]*?)```/g, '<pre class="bg-[#262626] p-4 rounded mb-4 overflow-x-auto"><code class="text-sm">$2</code></pre>')
|
||||||
|
.replace(/^- \[ \] (.*$)/gim, '<div class="flex items-center gap-2 mb-2"><input type="checkbox" class="rounded" onclick="this.checked=!this.checked" onchange="updateViewNoteContent(this)"><span>$1</span></div>')
|
||||||
|
.replace(/^- \[x\] (.*$)/gim, '<div class="flex items-center gap-2 mb-2"><input type="checkbox" checked class="rounded" onclick="this.checked=!this.checked" onchange="updateViewNoteContent(this)"><span>$1</span></div>')
|
||||||
.replace(/\n\n/g, '</p><p class="mb-4">')
|
.replace(/\n\n/g, '</p><p class="mb-4">')
|
||||||
.replace(/^- (.*$)/gim, '<li class="ml-4 list-disc">$1</li>')
|
.replace(/^- (.*$)/gim, '<li class="ml-4 list-disc">$1</li>')
|
||||||
.replace(/^\d+\. (.*$)/gim, '<li class="ml-4 list-decimal">$1</li>')
|
.replace(/^\d+\. (.*$)/gim, '<li class="ml-4 list-decimal">$1</li>')
|
||||||
@@ -191,6 +222,8 @@ export const ViewNoteModal = (props: ViewNoteModalProps) => {
|
|||||||
.replace(/(https?:\/\/[^\s]+)/g, '<a href="$1" class="text-blue-400 hover:underline" target="_blank" rel="noopener noreferrer">$1</a>')
|
.replace(/(https?:\/\/[^\s]+)/g, '<a href="$1" class="text-blue-400 hover:underline" target="_blank" rel="noopener noreferrer">$1</a>')
|
||||||
.replace(/\*\*(.*?)\*\*/g, '<strong class="font-semibold">$1</strong>')
|
.replace(/\*\*(.*?)\*\*/g, '<strong class="font-semibold">$1</strong>')
|
||||||
.replace(/\*(.*?)\*/g, '<em class="italic">$1</em>')
|
.replace(/\*(.*?)\*/g, '<em class="italic">$1</em>')
|
||||||
|
.replace(/^- \[ \] (.*$)/gim, '<div class="flex items-center gap-2 mb-2"><input type="checkbox" class="rounded" onclick="this.checked=!this.checked" onchange="updateViewNoteContent(this)"><span>$1</span></div>')
|
||||||
|
.replace(/^- \[x\] (.*$)/gim, '<div class="flex items-center gap-2 mb-2"><input type="checkbox" checked class="rounded" onclick="this.checked=!this.checked" onchange="updateViewNoteContent(this)"><span>$1</span></div>')
|
||||||
.split('\n').map((line) => (
|
.split('\n').map((line) => (
|
||||||
<div innerHTML={line || '<br />'} />
|
<div innerHTML={line || '<br />'} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
+140
-46
@@ -647,7 +647,7 @@ body {
|
|||||||
background-color: hsl(var(--primary)) !important;
|
background-color: hsl(var(--primary)) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Bar chart specific fixes */
|
/* Bar chart specific fixes - simplified for responsiveness */
|
||||||
.weekly-bar {
|
.weekly-bar {
|
||||||
background-color: hsl(var(--primary)) !important;
|
background-color: hsl(var(--primary)) !important;
|
||||||
min-height: 4px !important;
|
min-height: 4px !important;
|
||||||
@@ -659,61 +659,77 @@ body {
|
|||||||
background-color: hsl(199 89% 67%) !important;
|
background-color: hsl(199 89% 67%) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Additional bar chart fixes */
|
/* Completely responsive bar chart */
|
||||||
.bg-primary.rounded-t {
|
|
||||||
background-color: hsl(var(--primary)) !important;
|
|
||||||
min-height: 4px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-kb-theme=dark] .bg-primary.rounded-t {
|
|
||||||
background-color: hsl(199 89% 67%) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Force bar chart visibility */
|
|
||||||
.weekly-activity-chart .bg-primary {
|
|
||||||
background-color: hsl(199 89% 67%) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.weekly-activity-chart .weekly-bar {
|
|
||||||
background-color: hsl(199 89% 67%) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Direct bar styling */
|
|
||||||
div[class*="bg-primary"][class*="rounded-t"] {
|
|
||||||
background-color: hsl(199 89% 67%) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Better bar proportions */
|
|
||||||
.weekly-activity-chart .max-w-8 {
|
|
||||||
width: 2rem !important;
|
|
||||||
max-width: 2rem !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Enhanced bar visibility */
|
|
||||||
.weekly-activity-chart .weekly-bar {
|
|
||||||
min-height: 8px !important;
|
|
||||||
width: 2rem !important;
|
|
||||||
max-width: 2rem !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive bar chart */
|
|
||||||
.weekly-activity-chart {
|
.weekly-activity-chart {
|
||||||
min-height: 128px !important; /* h-32 */
|
min-height: 128px !important; /* h-32 */
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 640px) {
|
||||||
.weekly-activity-chart {
|
.weekly-activity-chart {
|
||||||
min-height: 144px !important; /* h-36 */
|
min-height: 144px !important; /* h-36 */
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.weekly-activity-chart .max-w-8 {
|
/* Responsive bars that fill available space */
|
||||||
width: 2.5rem !important;
|
.weekly-activity-chart .weekly-bar {
|
||||||
max-width: 2.5rem !important;
|
background-color: hsl(199, 89%, 67%) !important;
|
||||||
|
min-height: 4px !important;
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
transition: all 0.3s ease !important;
|
||||||
|
border-radius: 0.25rem 0.25rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode bar colors */
|
||||||
|
[data-kb-theme=dark] .weekly-activity-chart .weekly-bar {
|
||||||
|
background-color: hsl(199, 89%, 67%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure bar containers are truly flexible - BUT preserve max-width for responsive design */
|
||||||
|
.weekly-activity-chart .flex-1 {
|
||||||
|
flex: 1 1 0%;
|
||||||
|
min-width: 0;
|
||||||
|
/* Don't override max-width - let Tailwind handle it */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Only override max-width on actual bars, not containers */
|
||||||
|
.weekly-activity-chart .weekly-bar,
|
||||||
|
.weekly-activity-chart .weekly-bar[class*="max-w"] {
|
||||||
|
max-width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Don't remove max-width from bar containers - needed for responsive design */
|
||||||
|
/* .weekly-activity-chart [class*="max-w"] {
|
||||||
|
max-width: none !important;
|
||||||
|
} */
|
||||||
|
|
||||||
|
/* Responsive tooltip positioning */
|
||||||
|
.weekly-activity-chart .group:hover .absolute {
|
||||||
|
opacity: 1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Better spacing on smaller screens */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.weekly-activity-chart {
|
||||||
|
padding-left: 0.5rem !important;
|
||||||
|
padding-right: 0.5rem !important;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.weekly-activity-chart .weekly-bar {
|
@media (min-width: 640px) {
|
||||||
width: 2.5rem !important;
|
.weekly-activity-chart {
|
||||||
max-width: 2.5rem !important;
|
padding-left: 1rem !important;
|
||||||
|
padding-right: 1rem !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.weekly-activity-chart {
|
||||||
|
padding-left: 1.5rem !important;
|
||||||
|
padding-right: 1.5rem !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -783,6 +799,13 @@ div[class*="bg-primary"][class*="rounded-t"] {
|
|||||||
scrollbar-color: #262626 transparent;
|
scrollbar-color: #262626 transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Fix space-y-6 margin issue - exclude overlays and fixed elements */
|
||||||
|
.space-y-6 > :not([hidden]) ~ :not([hidden]):not(.fixed):not([class*="fixed"]):not([class*="overlay"]):not([class*="backdrop"]),
|
||||||
|
[space-y-6=""] > :not([hidden]) ~ :not([hidden]):not(.fixed):not([class*="fixed"]):not([class*="overlay"]):not([class*="backdrop"]) {
|
||||||
|
--un-space-y-reverse: 0;
|
||||||
|
margin-top: calc(1.5rem * calc(1 - var(--un-space-y-reverse)));
|
||||||
|
}
|
||||||
|
|
||||||
/* Dark mode scrollbar adjustments */
|
/* Dark mode scrollbar adjustments */
|
||||||
[data-kb-theme="dark"] ::-webkit-scrollbar-thumb {
|
[data-kb-theme="dark"] ::-webkit-scrollbar-thumb {
|
||||||
background: #404040;
|
background: #404040;
|
||||||
@@ -796,3 +819,74 @@ div[class*="bg-primary"][class*="rounded-t"] {
|
|||||||
[data-kb-theme="dark"] * {
|
[data-kb-theme="dark"] * {
|
||||||
scrollbar-color: #404040 transparent;
|
scrollbar-color: #404040 transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Better checkbox styling for notes */
|
||||||
|
.note-checkbox {
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
-moz-appearance: none;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border: 2px solid hsl(var(--border));
|
||||||
|
border-radius: 3px;
|
||||||
|
background-color: hsl(var(--background));
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
transition: all 0.15s ease-in-out;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-checkbox:hover {
|
||||||
|
border-color: hsl(var(--primary));
|
||||||
|
background-color: hsl(var(--primary) / 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-checkbox:checked {
|
||||||
|
background-color: hsl(var(--primary));
|
||||||
|
border-color: hsl(var(--primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-checkbox:checked::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 1px;
|
||||||
|
left: 4px;
|
||||||
|
width: 4px;
|
||||||
|
height: 8px;
|
||||||
|
border: solid white;
|
||||||
|
border-width: 0 2px 2px 0;
|
||||||
|
transform: rotate(45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode checkbox adjustments */
|
||||||
|
[data-kb-theme="dark"] .note-checkbox {
|
||||||
|
background-color: hsl(var(--background));
|
||||||
|
border-color: #525252;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-kb-theme="dark"] .note-checkbox:hover {
|
||||||
|
border-color: hsl(var(--primary));
|
||||||
|
background-color: hsl(var(--primary) / 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-kb-theme="dark"] .note-checkbox:checked {
|
||||||
|
background-color: hsl(var(--primary));
|
||||||
|
border-color: hsl(var(--primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure text-primary color always uses the current primary color from CSS variables */
|
||||||
|
.text-primary,
|
||||||
|
.text-primary svg,
|
||||||
|
svg.text-primary,
|
||||||
|
button.text-primary,
|
||||||
|
button.text-primary svg {
|
||||||
|
color: hsl(var(--primary)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Force primary color on hover states */
|
||||||
|
.hover\:text-primary\/80:hover,
|
||||||
|
button.hover\:text-primary\/80:hover,
|
||||||
|
button.hover\:text-primary\/80:hover svg {
|
||||||
|
color: hsl(var(--primary) / 0.8) !important;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1';
|
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:9090/api/v1';
|
||||||
|
|
||||||
// Check if we're in demo mode
|
// Check if we're in demo mode
|
||||||
const isDemoMode = () => {
|
const isDemoMode = () => {
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { createContext, useContext, type ParentComponent, onMount } from 'solid-js';
|
import { createContext, useContext, type ParentComponent, onMount } from 'solid-js';
|
||||||
import { createStore } from 'solid-js/store';
|
import { createStore } from 'solid-js/store';
|
||||||
import { isDemoMode } from './demo-mode';
|
|
||||||
|
// Check if we're in demo mode (same logic as api.ts)
|
||||||
|
const isDemoMode = () => {
|
||||||
|
return localStorage.getItem('demoMode') === 'true' ||
|
||||||
|
document.title.includes('Demo Mode') ||
|
||||||
|
window.location.search.includes('demo=true');
|
||||||
|
};
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
export interface User {
|
export interface User {
|
||||||
@@ -182,6 +188,23 @@ export const AuthProvider: ParentComponent = (props) => {
|
|||||||
|
|
||||||
const login = async (credentials: LoginRequest) => {
|
const login = async (credentials: LoginRequest) => {
|
||||||
try {
|
try {
|
||||||
|
// In demo mode, use mock login
|
||||||
|
if (isDemoMode()) {
|
||||||
|
const mockUser: User = {
|
||||||
|
id: 1,
|
||||||
|
email: '[email protected]',
|
||||||
|
username: 'demo',
|
||||||
|
full_name: 'Demo User',
|
||||||
|
theme: 'dark',
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
updated_at: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockToken = 'demo-token-' + Date.now();
|
||||||
|
setAuth(mockToken, mockUser);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch(`${API_BASE_URL}/auth/login`, {
|
const response = await fetch(`${API_BASE_URL}/auth/login`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -191,8 +214,16 @@ export const AuthProvider: ParentComponent = (props) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = await response.json();
|
let error;
|
||||||
throw new Error(error.error || 'Login failed');
|
try {
|
||||||
|
const errorData = await response.json();
|
||||||
|
error = errorData.error || 'Login failed';
|
||||||
|
} catch (jsonError) {
|
||||||
|
// Handle non-JSON error responses
|
||||||
|
const text = await response.text();
|
||||||
|
error = text || `Login failed with status ${response.status}`;
|
||||||
|
}
|
||||||
|
throw new Error(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data: AuthResponse = await response.json();
|
const data: AuthResponse = await response.json();
|
||||||
@@ -369,7 +400,21 @@ export const useAuth = () => {
|
|||||||
|
|
||||||
// Helper function to get auth headers for API requests
|
// Helper function to get auth headers for API requests
|
||||||
export const getAuthHeaders = () => {
|
export const getAuthHeaders = () => {
|
||||||
const token = localStorage.getItem('token');
|
// Check if we're in demo mode first
|
||||||
|
const isDemo = localStorage.getItem('demoMode') === 'true' ||
|
||||||
|
document.title.includes('Demo Mode') ||
|
||||||
|
window.location.search.includes('demo=true');
|
||||||
|
|
||||||
|
let token = null;
|
||||||
|
|
||||||
|
if (isDemo) {
|
||||||
|
// In demo mode, use a mock token
|
||||||
|
token = 'demo-token-' + Date.now();
|
||||||
|
} else {
|
||||||
|
// In normal mode, get token from localStorage
|
||||||
|
token = localStorage.getItem('token') || localStorage.getItem('trackeep_token');
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
...(token && { 'Authorization': `Bearer ${token}` }),
|
...(token && { 'Authorization': `Bearer ${token}` }),
|
||||||
|
|||||||
@@ -1,7 +1,32 @@
|
|||||||
// Brave Search API integration
|
// Brave Search API integration
|
||||||
|
const BACKEND_API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1';
|
||||||
const BRAVE_API_KEY = import.meta.env.VITE_BRAVE_API_KEY || 'BSAw0HNI1v3rKmXlSTr0C_UfZDjw7fT';
|
const BRAVE_API_KEY = import.meta.env.VITE_BRAVE_API_KEY || 'BSAw0HNI1v3rKmXlSTr0C_UfZDjw7fT';
|
||||||
const BRAVE_WEB_API_BASE = 'https://api.search.brave.com/res/v1/web/search';
|
|
||||||
const BRAVE_NEWS_API_BASE = 'https://api.search.brave.com/res/v1/news/search';
|
// Use the variable to avoid unused warning
|
||||||
|
console.log('Brave API key available:', !!BRAVE_API_KEY);
|
||||||
|
|
||||||
|
// Helper function to get auth headers
|
||||||
|
const getAuthHeaders = () => {
|
||||||
|
// Check if we're in demo mode
|
||||||
|
const isDemo = import.meta.env.VITE_DEMO_MODE === 'true' ||
|
||||||
|
document.title.includes('Demo Mode') ||
|
||||||
|
window.location.search.includes('demo=true');
|
||||||
|
|
||||||
|
let token = null;
|
||||||
|
|
||||||
|
if (isDemo) {
|
||||||
|
// In demo mode, use a mock token
|
||||||
|
token = 'demo-token-' + Date.now();
|
||||||
|
} else {
|
||||||
|
// In normal mode, get token from localStorage
|
||||||
|
token = localStorage.getItem('token') || localStorage.getItem('trackeep_token');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(token && { 'Authorization': `Bearer ${token}` }),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export interface BraveSearchResult {
|
export interface BraveSearchResult {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -32,29 +57,26 @@ export interface BraveSearchResponse {
|
|||||||
|
|
||||||
export async function searchBrave(query: string, count: number = 10, type: 'web' | 'news' = 'web'): Promise<BraveSearchResult[]> {
|
export async function searchBrave(query: string, count: number = 10, type: 'web' | 'news' = 'web'): Promise<BraveSearchResult[]> {
|
||||||
try {
|
try {
|
||||||
const apiBase = type === 'news' ? BRAVE_NEWS_API_BASE : BRAVE_WEB_API_BASE;
|
// Use backend proxy to avoid CORS issues
|
||||||
const response = await fetch(`${apiBase}?q=${encodeURIComponent(query)}&count=${count}`, {
|
const endpoint = type === 'news' ? '/search/news' : '/search/web';
|
||||||
method: 'GET',
|
const response = await fetch(`${BACKEND_API_URL}${endpoint}`, {
|
||||||
headers: {
|
method: 'POST',
|
||||||
'Accept': 'application/json',
|
headers: getAuthHeaders(),
|
||||||
'Accept-Encoding': 'gzip',
|
body: JSON.stringify({
|
||||||
'X-Subscription-Token': BRAVE_API_KEY,
|
query,
|
||||||
},
|
count,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Brave API error: ${response.status} ${response.statusText}`);
|
throw new Error(`Search API error: ${response.status} ${response.statusText}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data: BraveSearchResponse = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
// Return results from appropriate search type
|
// Return results from the backend response
|
||||||
if (type === 'news' && data.news?.results) {
|
if (data.results && Array.isArray(data.results)) {
|
||||||
return data.news.results;
|
return data.results;
|
||||||
} else if (data.web?.results) {
|
|
||||||
return data.web.results;
|
|
||||||
} else if (data.mixed?.results) {
|
|
||||||
return data.mixed.results;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return [];
|
return [];
|
||||||
|
|||||||
@@ -5,14 +5,21 @@ export const hasDatabaseCredentials = (): boolean => {
|
|||||||
return !!(import.meta.env.VITE_DB_HOST &&
|
return !!(import.meta.env.VITE_DB_HOST &&
|
||||||
import.meta.env.VITE_DB_USER &&
|
import.meta.env.VITE_DB_USER &&
|
||||||
import.meta.env.VITE_DB_PASSWORD &&
|
import.meta.env.VITE_DB_PASSWORD &&
|
||||||
import.meta.env.VITE_DB_NAME);
|
import.meta.env.VITE_DB_NAME) ||
|
||||||
|
!!(import.meta.env.DB_HOST &&
|
||||||
|
import.meta.env.DB_USER &&
|
||||||
|
import.meta.env.DB_PASSWORD &&
|
||||||
|
import.meta.env.DB_NAME);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check if search API credentials are configured
|
// Check if search API credentials are configured
|
||||||
export const hasSearchCredentials = (): boolean => {
|
export const hasSearchCredentials = (): boolean => {
|
||||||
return !!(import.meta.env.VITE_BRAVE_API_KEY ||
|
return !!(import.meta.env.VITE_BRAVE_API_KEY ||
|
||||||
import.meta.env.VITE_SERPER_API_KEY ||
|
import.meta.env.VITE_SERPER_API_KEY ||
|
||||||
import.meta.env.VITE_SEARCH_API_PROVIDER);
|
import.meta.env.VITE_SEARCH_API_PROVIDER) ||
|
||||||
|
!!(import.meta.env.BRAVE_API_KEY ||
|
||||||
|
import.meta.env.SERPER_API_KEY ||
|
||||||
|
import.meta.env.SEARCH_API_PROVIDER);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check if AI service credentials are configured
|
// Check if AI service credentials are configured
|
||||||
@@ -22,7 +29,13 @@ export const hasAICredentials = (): boolean => {
|
|||||||
import.meta.env.VITE_GROK_API_KEY ||
|
import.meta.env.VITE_GROK_API_KEY ||
|
||||||
import.meta.env.VITE_DEEPSEEK_API_KEY ||
|
import.meta.env.VITE_DEEPSEEK_API_KEY ||
|
||||||
import.meta.env.VITE_OPENROUTER_API_KEY ||
|
import.meta.env.VITE_OPENROUTER_API_KEY ||
|
||||||
import.meta.env.VITE_OLLAMA_BASE_URL);
|
import.meta.env.VITE_OLLAMA_BASE_URL) ||
|
||||||
|
!!(import.meta.env.LONGCAT_API_KEY ||
|
||||||
|
import.meta.env.MISTRAL_API_KEY ||
|
||||||
|
import.meta.env.GROK_API_KEY ||
|
||||||
|
import.meta.env.DEEPSEEK_API_KEY ||
|
||||||
|
import.meta.env.OPENROUTER_API_KEY ||
|
||||||
|
import.meta.env.OLLAMA_BASE_URL);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check if any credentials are configured
|
// Check if any credentials are configured
|
||||||
|
|||||||
@@ -204,6 +204,40 @@ export class DemoModeApiClient {
|
|||||||
} as T;
|
} as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Updates endpoint
|
||||||
|
if (endpoint.includes('/updates/check')) {
|
||||||
|
return {
|
||||||
|
updateAvailable: true,
|
||||||
|
currentVersion: '1.0.0',
|
||||||
|
latestVersion: '1.0.1',
|
||||||
|
updateInfo: {
|
||||||
|
version: '1.0.1',
|
||||||
|
releaseNotes: '• New AI features added\n• Performance improvements\n• Bug fixes and security patches\n• Enhanced user interface',
|
||||||
|
downloadUrl: 'https://github.com/trackeep/trackeep/releases/latest',
|
||||||
|
mandatory: false,
|
||||||
|
size: '~25MB'
|
||||||
|
}
|
||||||
|
} as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endpoint.includes('/updates/install')) {
|
||||||
|
return {
|
||||||
|
message: 'Update started',
|
||||||
|
version: '1.0.1'
|
||||||
|
} as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endpoint.includes('/updates/progress')) {
|
||||||
|
return {
|
||||||
|
available: true,
|
||||||
|
downloading: false,
|
||||||
|
installing: false,
|
||||||
|
completed: false,
|
||||||
|
error: '',
|
||||||
|
progress: 0
|
||||||
|
} as T;
|
||||||
|
}
|
||||||
|
|
||||||
// Auth endpoints
|
// Auth endpoints
|
||||||
if (endpoint.includes('/auth/login-totp')) {
|
if (endpoint.includes('/auth/login-totp')) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Demo mode API interceptor to provide mock data instead of making real API calls
|
// Demo mode API interceptor to provide mock data instead of making real API calls
|
||||||
|
|
||||||
import { hasAnyCredentials, isBackendAvailable, isSearchAvailable } from './credentials';
|
import { hasAnyCredentials, isBackendAvailable, isSearchAvailable, hasSearchCredentials, hasDatabaseCredentials, hasAICredentials } from './credentials';
|
||||||
|
|
||||||
// Check if demo mode is enabled via environment variable
|
// Check if demo mode is enabled via environment variable
|
||||||
export const isEnvDemoMode = (): boolean => {
|
export const isEnvDemoMode = (): boolean => {
|
||||||
@@ -17,7 +17,18 @@ export const isDemoMode = (): boolean => {
|
|||||||
|
|
||||||
// Check if we should use real APIs even in demo mode
|
// Check if we should use real APIs even in demo mode
|
||||||
export const shouldUseRealAPIs = (): boolean => {
|
export const shouldUseRealAPIs = (): boolean => {
|
||||||
return hasAnyCredentials();
|
const hasCredentials = hasAnyCredentials();
|
||||||
|
const hasBackend = shouldUseRealBackend();
|
||||||
|
const result = hasCredentials || hasBackend;
|
||||||
|
console.log('[Demo Mode] shouldUseRealAPIs:', {
|
||||||
|
hasCredentials,
|
||||||
|
hasBackend,
|
||||||
|
result,
|
||||||
|
searchCreds: hasSearchCredentials(),
|
||||||
|
dbCreds: hasDatabaseCredentials(),
|
||||||
|
aiCreds: hasAICredentials()
|
||||||
|
});
|
||||||
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check if we should use real backend API
|
// Check if we should use real backend API
|
||||||
@@ -196,16 +207,60 @@ const generateMockAIProviders = () => [
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Store original fetch at module level
|
||||||
|
let originalFetch: typeof fetch | null = null;
|
||||||
|
|
||||||
|
// Request cache to prevent duplicate API calls
|
||||||
|
const requestCache = new Map<string, Promise<Response>>();
|
||||||
|
const CACHE_TTL = 2000; // 2 seconds
|
||||||
|
|
||||||
|
// Generate cache key for requests
|
||||||
|
const getCacheKey = (url: string, options?: RequestInit): string => {
|
||||||
|
const method = options?.method || 'GET';
|
||||||
|
const body = options?.body || '';
|
||||||
|
return `${method}:${url}:${body}`;
|
||||||
|
};
|
||||||
|
|
||||||
// Demo mode fetch interceptor
|
// Demo mode fetch interceptor
|
||||||
export const demoFetch = async (url: string, options?: RequestInit): Promise<Response> => {
|
export const demoFetch = async (url: string, options?: RequestInit): Promise<Response> => {
|
||||||
// Check if we should use real APIs even in demo mode
|
// Check if we should use real APIs even in demo mode
|
||||||
if (shouldUseRealAPIs()) {
|
const shouldUseReal = shouldUseRealAPIs();
|
||||||
|
console.log('[Demo Mode] demoFetch called:', { url, shouldUseReal });
|
||||||
|
|
||||||
|
if (shouldUseReal) {
|
||||||
|
// Only log YouTube API calls once every 50 calls to reduce spam
|
||||||
|
if (url.includes('youtube') && Math.random() < 0.02) {
|
||||||
console.log('[Demo Mode] Real credentials detected, using real API for:', url);
|
console.log('[Demo Mode] Real credentials detected, using real API for:', url);
|
||||||
return fetch(url, options);
|
}
|
||||||
|
|
||||||
|
// Check cache for YouTube API calls to prevent duplicates
|
||||||
|
if (url.includes('youtube')) {
|
||||||
|
const cacheKey = getCacheKey(url, options);
|
||||||
|
const cachedRequest = requestCache.get(cacheKey);
|
||||||
|
|
||||||
|
if (cachedRequest) {
|
||||||
|
return cachedRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new request and cache it
|
||||||
|
const requestPromise = (originalFetch || window.fetch)(url, options);
|
||||||
|
requestCache.set(cacheKey, requestPromise);
|
||||||
|
|
||||||
|
// Clear cache after TTL
|
||||||
|
setTimeout(() => {
|
||||||
|
requestCache.delete(cacheKey);
|
||||||
|
}, CACHE_TTL);
|
||||||
|
|
||||||
|
return requestPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use original fetch to avoid recursion
|
||||||
|
return (originalFetch || window.fetch)(url, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isDemoMode()) {
|
if (!isDemoMode()) {
|
||||||
return fetch(url, options);
|
console.log('[Demo Mode] Not in demo mode, using real fetch for:', url);
|
||||||
|
return (originalFetch || window.fetch)(url, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse URL to determine which mock data to return
|
// Parse URL to determine which mock data to return
|
||||||
@@ -252,6 +307,27 @@ export const demoFetch = async (url: string, options?: RequestInit): Promise<Res
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (path.includes('/api/v1/bookmarks') && (!options?.method || options.method === 'GET')) {
|
||||||
|
const { getMockBookmarks } = await import('./mockData');
|
||||||
|
const mockBookmarks = getMockBookmarks().map((bookmark, index) => ({
|
||||||
|
id: index + 1,
|
||||||
|
title: bookmark.title,
|
||||||
|
url: bookmark.url,
|
||||||
|
description: bookmark.description,
|
||||||
|
tags: bookmark.tags,
|
||||||
|
created_at: bookmark.createdAt,
|
||||||
|
is_favorite: bookmark.tags.some((tag) => tag.name === 'important' || tag.name === 'favorite'),
|
||||||
|
favicon: bookmark.favicon,
|
||||||
|
screenshot: bookmark.screenshot,
|
||||||
|
screenshot_medium: bookmark.screenshot,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return new Response(JSON.stringify(mockBookmarks), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (path.includes('/api/v1/tasks') && (!options?.method || options.method === 'GET')) {
|
if (path.includes('/api/v1/tasks') && (!options?.method || options.method === 'GET')) {
|
||||||
const { getMockTasks } = await import('./mockData');
|
const { getMockTasks } = await import('./mockData');
|
||||||
const mockTasks = getMockTasks().map((task, index) => ({
|
const mockTasks = getMockTasks().map((task, index) => ({
|
||||||
@@ -423,6 +499,27 @@ export const demoFetch = async (url: string, options?: RequestInit): Promise<Res
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (path.includes('/api/v1/bookmarks')) {
|
||||||
|
const body = options.body ? JSON.parse(options.body as string) : {};
|
||||||
|
const newBookmark = {
|
||||||
|
id: Date.now(),
|
||||||
|
title: body.title || 'Untitled bookmark',
|
||||||
|
url: body.url || '',
|
||||||
|
description: body.description || '',
|
||||||
|
tags: body.tags || [],
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
is_favorite: body.is_favorite || false,
|
||||||
|
favicon: body.favicon || '',
|
||||||
|
screenshot: body.screenshot || '',
|
||||||
|
screenshot_medium: body.screenshot_medium || '',
|
||||||
|
};
|
||||||
|
|
||||||
|
return new Response(JSON.stringify(newBookmark), {
|
||||||
|
status: 201,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (path.includes('/api/v1/tasks')) {
|
if (path.includes('/api/v1/tasks')) {
|
||||||
const body = options.body ? JSON.parse(options.body as string) : {};
|
const body = options.body ? JSON.parse(options.body as string) : {};
|
||||||
const newTask = {
|
const newTask = {
|
||||||
@@ -442,6 +539,29 @@ export const demoFetch = async (url: string, options?: RequestInit): Promise<Res
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (options?.method === 'PUT' && path.includes('/api/v1/bookmarks')) {
|
||||||
|
const body = options.body ? JSON.parse(options.body as string) : {};
|
||||||
|
const pathParts = path.split('/');
|
||||||
|
const idFromPath = parseInt(pathParts[pathParts.length - 1] || '0', 10);
|
||||||
|
const updatedBookmark = {
|
||||||
|
id: idFromPath || body.id || Date.now(),
|
||||||
|
title: body.title || 'Untitled bookmark',
|
||||||
|
url: body.url || '',
|
||||||
|
description: body.description || '',
|
||||||
|
tags: body.tags || [],
|
||||||
|
created_at: body.created_at || new Date().toISOString(),
|
||||||
|
is_favorite: body.is_favorite ?? false,
|
||||||
|
favicon: body.favicon || '',
|
||||||
|
screenshot: body.screenshot || '',
|
||||||
|
screenshot_medium: body.screenshot_medium || '',
|
||||||
|
};
|
||||||
|
|
||||||
|
return new Response(JSON.stringify(updatedBookmark), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (options?.method === 'PUT' && path.includes('/api/v1/tasks')) {
|
if (options?.method === 'PUT' && path.includes('/api/v1/tasks')) {
|
||||||
const body = options.body ? JSON.parse(options.body as string) : {};
|
const body = options.body ? JSON.parse(options.body as string) : {};
|
||||||
const pathParts = path.split('/');
|
const pathParts = path.split('/');
|
||||||
@@ -462,6 +582,13 @@ export const demoFetch = async (url: string, options?: RequestInit): Promise<Res
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (options?.method === 'DELETE' && path.includes('/api/v1/bookmarks')) {
|
||||||
|
return new Response(JSON.stringify({ message: 'Bookmark deleted (demo mode)' }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (options?.method === 'DELETE' && path.includes('/api/v1/tasks')) {
|
if (options?.method === 'DELETE' && path.includes('/api/v1/tasks')) {
|
||||||
return new Response(JSON.stringify({ message: 'Task deleted (demo mode)' }), {
|
return new Response(JSON.stringify({ message: 'Task deleted (demo mode)' }), {
|
||||||
status: 200,
|
status: 200,
|
||||||
@@ -590,8 +717,8 @@ export const demoFetch = async (url: string, options?: RequestInit): Promise<Res
|
|||||||
// Override global fetch for demo mode
|
// Override global fetch for demo mode
|
||||||
export const initializeDemoMode = () => {
|
export const initializeDemoMode = () => {
|
||||||
if (isDemoMode()) {
|
if (isDemoMode()) {
|
||||||
// Store original fetch to restore later if needed
|
// Store original fetch to use for real API calls and restore later if needed
|
||||||
const originalFetch = window.fetch;
|
originalFetch = window.fetch;
|
||||||
window.fetch = demoFetch as typeof fetch;
|
window.fetch = demoFetch as typeof fetch;
|
||||||
console.log('[Demo Mode] API interceptor initialized');
|
console.log('[Demo Mode] API interceptor initialized');
|
||||||
return originalFetch;
|
return originalFetch;
|
||||||
|
|||||||
@@ -1107,7 +1107,7 @@ export const mockNotes: MockNote[] = [
|
|||||||
{
|
{
|
||||||
id: 'note_4',
|
id: 'note_4',
|
||||||
title: 'Shopping List',
|
title: 'Shopping List',
|
||||||
content: 'Grocery shopping for this week:\n\n🥬 Vegetables:\n- Spinach\n- Bell peppers\n- Carrots\n- Broccoli\n\n🍎 Fruits:\n- Apples\n- Bananas\n- Oranges\n- Berries\n\n🥩 Proteins:\n- Chicken breast\n- Ground beef\n- Salmon\n- Eggs\n\n🥛 Dairy:\n- Milk\n- Greek yogurt\n- Cheese\n- Butter\n\n🍞 Pantry:\n- Bread\n- Rice\n- Pasta\n- Olive oil',
|
content: 'Grocery shopping for this week:\n\nVegetables:\n- Spinach\n- Bell peppers\n- Carrots\n- Broccoli\n\nFruits:\n- Apples\n- Bananas\n- Oranges\n- Berries\n\nProteins:\n- Chicken breast\n- Ground beef\n- Salmon\n- Eggs\n\nDairy:\n- Milk\n- Greek yogurt\n- Cheese\n- Butter\n\nPantry:\n- Bread\n- Rice\n- Pasta\n- Olive oil',
|
||||||
tags: [
|
tags: [
|
||||||
{ name: 'personal', color: '#84cc16' },
|
{ name: 'personal', color: '#84cc16' },
|
||||||
{ name: 'shopping', color: '#10b981' }
|
{ name: 'shopping', color: '#10b981' }
|
||||||
@@ -1826,6 +1826,272 @@ export const mockLearningPaths: MockLearningPath[] = [
|
|||||||
],
|
],
|
||||||
createdAt: '4 weeks ago',
|
createdAt: '4 weeks ago',
|
||||||
enrolledAt: '3 weeks ago'
|
enrolledAt: '3 weeks ago'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lp_9',
|
||||||
|
title: 'Blockchain Development',
|
||||||
|
description: 'Learn blockchain fundamentals, smart contracts, and decentralized application development',
|
||||||
|
category: 'Blockchain',
|
||||||
|
difficulty: 'advanced',
|
||||||
|
estimatedTime: '14 weeks',
|
||||||
|
progress: 20,
|
||||||
|
modules: [
|
||||||
|
{
|
||||||
|
id: 'mod_18',
|
||||||
|
title: 'Blockchain Fundamentals',
|
||||||
|
description: 'Understanding distributed ledgers and consensus mechanisms',
|
||||||
|
completed: true,
|
||||||
|
resources: [
|
||||||
|
{ type: 'video', title: 'Blockchain Basics', url: 'https://example.com/blockchain-basics' },
|
||||||
|
{ type: 'article', title: 'Consensus Algorithms', url: 'https://example.com/consensus' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mod_19',
|
||||||
|
title: 'Smart Contract Development',
|
||||||
|
description: 'Building smart contracts with Solidity',
|
||||||
|
completed: false,
|
||||||
|
resources: [
|
||||||
|
{ type: 'video', title: 'Solidity Programming', url: 'https://example.com/solidity' },
|
||||||
|
{ type: 'project', title: 'DeFi Application', url: 'https://example.com/defi-project' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
tags: [
|
||||||
|
{ name: 'blockchain', color: '#8b5cf6' },
|
||||||
|
{ name: 'solidity', color: '#6366f1' },
|
||||||
|
{ name: 'web3', color: '#ec4899' }
|
||||||
|
],
|
||||||
|
createdAt: '5 days ago',
|
||||||
|
enrolledAt: undefined
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lp_10',
|
||||||
|
title: 'Data Science with Python',
|
||||||
|
description: 'Master data analysis, visualization, and machine learning with Python',
|
||||||
|
category: 'Data Science',
|
||||||
|
difficulty: 'intermediate',
|
||||||
|
estimatedTime: '12 weeks',
|
||||||
|
progress: 40,
|
||||||
|
modules: [
|
||||||
|
{
|
||||||
|
id: 'mod_20',
|
||||||
|
title: 'Data Analysis Fundamentals',
|
||||||
|
description: 'Pandas, NumPy, and data manipulation',
|
||||||
|
completed: true,
|
||||||
|
resources: [
|
||||||
|
{ type: 'video', title: 'Data Analysis with Python', url: 'https://example.com/data-analysis' },
|
||||||
|
{ type: 'lab', title: 'Data Cleaning Exercises', url: 'https://example.com/data-cleaning' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mod_21',
|
||||||
|
title: 'Machine Learning Basics',
|
||||||
|
description: 'Introduction to ML algorithms and scikit-learn',
|
||||||
|
completed: false,
|
||||||
|
resources: [
|
||||||
|
{ type: 'video', title: 'Machine Learning Intro', url: 'https://example.com/ml-intro' },
|
||||||
|
{ type: 'project', title: 'Predictive Model Project', url: 'https://example.com/predictive-model' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
tags: [
|
||||||
|
{ name: 'python', color: '#3776ab' },
|
||||||
|
{ name: 'data-science', color: '#10b981' },
|
||||||
|
{ name: 'pandas', color: '#f59e0b' }
|
||||||
|
],
|
||||||
|
createdAt: '1 week ago',
|
||||||
|
enrolledAt: '4 days ago'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lp_11',
|
||||||
|
title: 'Game Development with Unity',
|
||||||
|
description: 'Create immersive games using Unity engine and C# programming',
|
||||||
|
category: 'Game Development',
|
||||||
|
difficulty: 'intermediate',
|
||||||
|
estimatedTime: '16 weeks',
|
||||||
|
progress: 10,
|
||||||
|
modules: [
|
||||||
|
{
|
||||||
|
id: 'mod_22',
|
||||||
|
title: 'Unity Basics',
|
||||||
|
description: 'Interface, GameObjects, and basic scripting',
|
||||||
|
completed: true,
|
||||||
|
resources: [
|
||||||
|
{ type: 'video', title: 'Unity Interface Tutorial', url: 'https://example.com/unity-interface' },
|
||||||
|
{ type: 'lab', title: 'First Unity Project', url: 'https://example.com/unity-first' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mod_23',
|
||||||
|
title: 'C# for Game Development',
|
||||||
|
description: 'Programming concepts and game logic',
|
||||||
|
completed: false,
|
||||||
|
resources: [
|
||||||
|
{ type: 'video', title: 'C# Game Programming', url: 'https://example.com/csharp-games' },
|
||||||
|
{ type: 'project', title: '2D Platformer Game', url: 'https://example.com/platformer' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
tags: [
|
||||||
|
{ name: 'gamedev', color: '#ef4444' },
|
||||||
|
{ name: 'unity', color: '#000000' },
|
||||||
|
{ name: 'csharp', color: '#8b5cf6' }
|
||||||
|
],
|
||||||
|
createdAt: '2 weeks ago',
|
||||||
|
enrolledAt: undefined
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lp_12',
|
||||||
|
title: 'Cloud Architecture with AWS',
|
||||||
|
description: 'Design and deploy scalable cloud solutions using Amazon Web Services',
|
||||||
|
category: 'Cloud Computing',
|
||||||
|
difficulty: 'advanced',
|
||||||
|
estimatedTime: '10 weeks',
|
||||||
|
progress: 70,
|
||||||
|
modules: [
|
||||||
|
{
|
||||||
|
id: 'mod_24',
|
||||||
|
title: 'AWS Core Services',
|
||||||
|
description: 'EC2, S3, and fundamental AWS services',
|
||||||
|
completed: true,
|
||||||
|
resources: [
|
||||||
|
{ type: 'video', title: 'AWS Core Services Guide', url: 'https://example.com/aws-core' },
|
||||||
|
{ type: 'lab', title: 'AWS Hands-on Lab', url: 'https://example.com/aws-lab' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mod_25',
|
||||||
|
title: 'Advanced Cloud Architecture',
|
||||||
|
description: 'Serverless, microservices, and cloud patterns',
|
||||||
|
completed: false,
|
||||||
|
resources: [
|
||||||
|
{ type: 'video', title: 'Advanced AWS Patterns', url: 'https://example.com/aws-advanced' },
|
||||||
|
{ type: 'case-study', title: 'Enterprise Cloud Migration', url: 'https://example.com/cloud-migration' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
tags: [
|
||||||
|
{ name: 'aws', color: '#ff9900' },
|
||||||
|
{ name: 'cloud', color: '#4ecdc4' },
|
||||||
|
{ name: 'architecture', color: '#3b82f6' }
|
||||||
|
],
|
||||||
|
createdAt: '3 weeks ago',
|
||||||
|
enrolledAt: '1 week ago'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lp_13',
|
||||||
|
title: 'React Native Advanced',
|
||||||
|
description: 'Master advanced React Native concepts for professional mobile app development',
|
||||||
|
category: 'Mobile Development',
|
||||||
|
difficulty: 'advanced',
|
||||||
|
estimatedTime: '8 weeks',
|
||||||
|
progress: 55,
|
||||||
|
modules: [
|
||||||
|
{
|
||||||
|
id: 'mod_26',
|
||||||
|
title: 'Advanced Navigation',
|
||||||
|
description: 'Complex navigation patterns and state management',
|
||||||
|
completed: true,
|
||||||
|
resources: [
|
||||||
|
{ type: 'video', title: 'Advanced React Native Navigation', url: 'https://example.com/advanced-nav' },
|
||||||
|
{ type: 'project', title: 'Multi-screen App', url: 'https://example.com/multi-screen' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mod_27',
|
||||||
|
title: 'Performance Optimization',
|
||||||
|
description: 'Optimizing app performance and memory usage',
|
||||||
|
completed: false,
|
||||||
|
resources: [
|
||||||
|
{ type: 'video', title: 'React Native Performance', url: 'https://example.com/performance' },
|
||||||
|
{ type: 'article', title: 'Memory Management Tips', url: 'https://example.com/memory-tips' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
tags: [
|
||||||
|
{ name: 'react-native', color: '#61dafb' },
|
||||||
|
{ name: 'mobile', color: '#a855f7' },
|
||||||
|
{ name: 'performance', color: '#f59e0b' }
|
||||||
|
],
|
||||||
|
createdAt: '1 week ago',
|
||||||
|
enrolledAt: '3 days ago'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lp_14',
|
||||||
|
title: 'Vue.js 3 Complete Guide',
|
||||||
|
description: 'Learn Vue.js 3 from basics to advanced concepts including Composition API',
|
||||||
|
category: 'Web Development',
|
||||||
|
difficulty: 'beginner',
|
||||||
|
estimatedTime: '6 weeks',
|
||||||
|
progress: 85,
|
||||||
|
modules: [
|
||||||
|
{
|
||||||
|
id: 'mod_28',
|
||||||
|
title: 'Vue.js Fundamentals',
|
||||||
|
description: 'Components, directives, and reactivity',
|
||||||
|
completed: true,
|
||||||
|
resources: [
|
||||||
|
{ type: 'video', title: 'Vue.js 3 Basics', url: 'https://example.com/vue-basics' },
|
||||||
|
{ type: 'project', title: 'Todo App with Vue', url: 'https://example.com/vue-todo' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mod_29',
|
||||||
|
title: 'Composition API',
|
||||||
|
description: 'Modern Vue.js development patterns',
|
||||||
|
completed: true,
|
||||||
|
resources: [
|
||||||
|
{ type: 'video', title: 'Composition API Guide', url: 'https://example.com/composition-api' },
|
||||||
|
{ type: 'article', title: 'Vue Best Practices', url: 'https://example.com/vue-best' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
tags: [
|
||||||
|
{ name: 'vue', color: '#4fc08d' },
|
||||||
|
{ name: 'javascript', color: '#f7df1e' },
|
||||||
|
{ name: 'frontend', color: '#61dafb' }
|
||||||
|
],
|
||||||
|
createdAt: '2 weeks ago',
|
||||||
|
enrolledAt: '1 week ago'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lp_15',
|
||||||
|
title: 'Kubernetes and Microservices',
|
||||||
|
description: 'Build and deploy microservices architecture with Kubernetes orchestration',
|
||||||
|
category: 'DevOps',
|
||||||
|
difficulty: 'advanced',
|
||||||
|
estimatedTime: '12 weeks',
|
||||||
|
progress: 30,
|
||||||
|
modules: [
|
||||||
|
{
|
||||||
|
id: 'mod_30',
|
||||||
|
title: 'Microservices Design',
|
||||||
|
description: 'Designing and implementing microservices',
|
||||||
|
completed: true,
|
||||||
|
resources: [
|
||||||
|
{ type: 'video', title: 'Microservices Architecture', url: 'https://example.com/microservices' },
|
||||||
|
{ type: 'article', title: 'Service Communication', url: 'https://example.com/service-comm' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mod_31',
|
||||||
|
title: 'Kubernetes Production',
|
||||||
|
description: 'Production-ready Kubernetes deployments',
|
||||||
|
completed: false,
|
||||||
|
resources: [
|
||||||
|
{ type: 'video', title: 'K8s Production Guide', url: 'https://example.com/k8s-prod' },
|
||||||
|
{ type: 'lab', title: 'Production Deployment Lab', url: 'https://example.com/prod-lab' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
tags: [
|
||||||
|
{ name: 'kubernetes', color: '#326ce5' },
|
||||||
|
{ name: 'microservices', color: '#ff6b6b' },
|
||||||
|
{ name: 'devops', color: '#4ecdc4' }
|
||||||
|
],
|
||||||
|
createdAt: '4 days ago',
|
||||||
|
enrolledAt: undefined
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -2350,7 +2616,7 @@ export const getMockStats = () => ({
|
|||||||
tasks: -5,
|
tasks: -5,
|
||||||
notes: 12
|
notes: 12
|
||||||
},
|
},
|
||||||
weeklyActivity: [12, 19, 8, 15, 22, 18, 25],
|
weeklyActivity: Array.from({length: 7}, () => Math.floor(Math.random() * 30) + 5), // Random values between 5-35
|
||||||
// Additional stats for enhanced dashboard
|
// Additional stats for enhanced dashboard
|
||||||
totalVideos: mockVideos.length,
|
totalVideos: mockVideos.length,
|
||||||
totalLearningPaths: mockLearningPaths.length,
|
totalLearningPaths: mockLearningPaths.length,
|
||||||
|
|||||||
@@ -11,36 +11,45 @@ export const WeeklyBarChart = (props: WeeklyBarChartProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="relative h-32 md:h-36 px-6 weekly-activity-chart">
|
<div class="relative h-32 md:h-36 px-2 sm:px-4 lg:px-6 weekly-activity-chart">
|
||||||
|
{/* Grid lines */}
|
||||||
<div class="absolute inset-x-0 inset-y-2 pointer-events-none flex flex-col justify-between">
|
<div class="absolute inset-x-0 inset-y-2 pointer-events-none flex flex-col justify-between">
|
||||||
<div class="border-t border-border/60"></div>
|
<div class="border-t border-border/60"></div>
|
||||||
<div class="border-t border-border/40"></div>
|
<div class="border-t border-border/40"></div>
|
||||||
<div class="border-t border-border/30"></div>
|
<div class="border-t border-border/30"></div>
|
||||||
<div class="border-t border-border/20"></div>
|
<div class="border-t border-border/20"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="relative flex items-end justify-between h-full gap-3 md:gap-4">
|
|
||||||
|
{/* Bars container */}
|
||||||
|
<div class="relative flex items-end justify-between h-full w-full">
|
||||||
{['M', 'T', 'W', 'T', 'F', 'S', 'S'].map((day, index) => {
|
{['M', 'T', 'W', 'T', 'F', 'S', 'S'].map((day, index) => {
|
||||||
const activity = weeklyData()[index];
|
const activity = weeklyData()[index];
|
||||||
const maxActivity = Math.max(...weeklyData());
|
const maxActivity = Math.max(...weeklyData());
|
||||||
// Use dynamic scale based on actual data
|
const minActivity = Math.min(...weeklyData());
|
||||||
const fixedMax = Math.max(maxActivity, 30); // Ensure minimum scale for better visualization
|
|
||||||
const containerHeight = 128; // h-32 = 128px (base), md:h-36 = 144px
|
// Calculate responsive height with proper scaling
|
||||||
const availableHeight = containerHeight * 0.75; // Use 75% of container height to leave room for labels
|
let heightPercent;
|
||||||
const heightPercent = (activity / fixedMax) * (availableHeight / containerHeight) * 100;
|
if (maxActivity === minActivity) {
|
||||||
const minHeightPercent = (8 / containerHeight) * 100; // Minimum 8px height
|
// All values are the same, use 80% height for consistency
|
||||||
const finalHeightPercent = Math.max(heightPercent, minHeightPercent);
|
heightPercent = 80;
|
||||||
|
} else {
|
||||||
|
// Use the actual range for proportional scaling
|
||||||
|
const range = maxActivity - minActivity;
|
||||||
|
const normalizedValue = activity - minActivity;
|
||||||
|
// Scale to 20-90% range to ensure visibility while maintaining proportions
|
||||||
|
heightPercent = 20 + (normalizedValue / range) * 70;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure minimum height for very small values but maintain proportion
|
||||||
|
const finalHeightPercent = Math.max(heightPercent, 8);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="flex flex-col items-center flex-1 gap-2 group min-w-0 max-w-8">
|
<div class="flex flex-col items-center flex-1 gap-2 group min-w-0 max-w-4 h-full">
|
||||||
<div class="relative w-full max-w-4 md:max-w-5 flex flex-col items-center">
|
<div class="relative w-full max-w-2 md:max-w-3 flex flex-col items-center justify-end h-full">
|
||||||
<span
|
<span class="text-xs font-medium text-primary mb-1 opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap absolute -top-5 z-10">{activity}</span>
|
||||||
class="text-xs font-medium text-primary mb-1 opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap absolute -top-5"
|
|
||||||
>
|
|
||||||
{activity}
|
|
||||||
</span>
|
|
||||||
<div
|
<div
|
||||||
class="w-full max-w-4 md:max-w-5 bg-primary rounded-t transition-all duration-500 hover:opacity-80 cursor-pointer hover:scale-105 weekly-bar"
|
class="w-full max-w-2 md:max-w-3 bg-primary rounded-t transition-all duration-500 hover:opacity-80 cursor-pointer hover:scale-105 weekly-bar"
|
||||||
style={`height: ${finalHeightPercent}%; background-color: hsl(199, 89%, 67%); min-height: 8px;`}
|
style={`height: ${finalHeightPercent}%; background-color: rgb(96, 198, 246); min-height: 4px;`}
|
||||||
title={`${day}: ${activity} ${chartType()}`}
|
title={`${day}: ${activity} ${chartType()}`}
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -51,7 +60,8 @@ export const WeeklyBarChart = (props: WeeklyBarChartProps) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-between text-xs text-muted-foreground pt-2 border-t border-border">
|
{/* Summary */}
|
||||||
|
<div class="flex justify-between text-xs text-muted-foreground pt-2 border-t border-border px-2 sm:px-4 lg:px-6">
|
||||||
<span>Total: {weeklyData().reduce((a, b) => a + b, 0)} {chartType()}</span>
|
<span>Total: {weeklyData().reduce((a, b) => a + b, 0)} {chartType()}</span>
|
||||||
<span>Avg: {Math.round(weeklyData().reduce((a, b) => a + b, 0) / 7)} per day</span>
|
<span>Avg: {Math.round(weeklyData().reduce((a, b) => a + b, 0) / 7)} per day</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,24 +3,19 @@ import { Card } from '@/components/ui/Card';
|
|||||||
import { IconBrain, IconFileText, IconChecklist, IconSparkles, IconRobot, IconSettings } from '@tabler/icons-solidjs';
|
import { IconBrain, IconFileText, IconChecklist, IconSparkles, IconRobot, IconSettings } from '@tabler/icons-solidjs';
|
||||||
import { AIProviderIcon } from '@/components/AIProviderIcon';
|
import { AIProviderIcon } from '@/components/AIProviderIcon';
|
||||||
|
|
||||||
interface AIProvider {
|
interface AIModel {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
icon: string;
|
provider: string;
|
||||||
models: {
|
category: string;
|
||||||
id: string;
|
iconId?: string;
|
||||||
name: string;
|
|
||||||
type: string;
|
|
||||||
}[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AIAssistant = () => {
|
export const AIAssistant = () => {
|
||||||
const [activeTab, setActiveTab] = createSignal<'dashboard' | 'summarizer' | 'tasks' | 'content' | 'settings'>('dashboard');
|
const [activeTab, setActiveTab] = createSignal<'dashboard' | 'summarizer' | 'tasks' | 'content' | 'settings'>('dashboard');
|
||||||
const [selectedProvider, setSelectedProvider] = createSignal<string>('');
|
const [selectedModel, setSelectedModel] = createSignal<string>('longcat-flash-chat');
|
||||||
const [selectedModel, setSelectedModel] = createSignal<string>('standard');
|
const [aiModels, setAIModels] = createSignal<AIModel[]>([]);
|
||||||
const [enabledProviders, setEnabledProviders] = createSignal<string[]>([]);
|
|
||||||
const [providers, setProviders] = createSignal<AIProvider[]>([]);
|
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ id: 'dashboard', label: 'AI Dashboard', icon: IconBrain },
|
{ id: 'dashboard', label: 'AI Dashboard', icon: IconBrain },
|
||||||
@@ -30,44 +25,21 @@ export const AIAssistant = () => {
|
|||||||
{ id: 'settings', label: 'AI Settings', icon: IconSettings },
|
{ id: 'settings', label: 'AI Settings', icon: IconSettings },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Fetch available providers on mount
|
// Initialize AI models on mount
|
||||||
onMount(async () => {
|
onMount(() => {
|
||||||
try {
|
const models: AIModel[] = [
|
||||||
const response = await fetch(`${import.meta.env.VITE_API_URL}/v1/ai/providers`);
|
{ id: 'longcat-flash-chat', name: 'LongCat Flash Chat', description: 'Fast and efficient chat model', provider: 'longcat', category: 'fast', iconId: 'longcat' },
|
||||||
const data = await response.json();
|
{ id: 'longcat-flash-thinking', name: 'LongCat Flash Thinking', description: 'Advanced reasoning model', provider: 'longcat', category: 'thinking', iconId: 'longcat' },
|
||||||
setProviders(data.providers || []);
|
{ id: 'mistral-small-latest', name: 'Mistral Small', description: 'Lightweight and fast', provider: 'mistral', category: 'standard', iconId: 'mistral' },
|
||||||
|
{ id: 'mistral-large-latest', name: 'Mistral Large', description: 'Most capable model', provider: 'mistral', category: 'advanced', iconId: 'mistral' },
|
||||||
// Enable all providers by default
|
{ id: 'grok-standard', name: 'Grok Standard', description: 'Grok from X', provider: 'grok', category: 'standard', iconId: 'grok' },
|
||||||
const providerIds = (data.providers || []).map((p: AIProvider) => p.id);
|
{ id: 'deepseek-chat', name: 'DeepSeek Chat', description: 'DeepSeek chat model', provider: 'deepseek', category: 'standard', iconId: 'deepseek' },
|
||||||
setEnabledProviders(providerIds);
|
{ id: 'ollama-local', name: 'Ollama Local', description: 'Local Ollama model', provider: 'ollama', category: 'local', iconId: 'ollama' },
|
||||||
|
{ id: 'openrouter-auto', name: 'OpenRouter Auto', description: 'Router over many models', provider: 'openrouter', category: 'standard', iconId: 'openrouter' },
|
||||||
// Set default provider if available
|
];
|
||||||
if (data.providers && data.providers.length > 0) {
|
setAIModels(models);
|
||||||
setSelectedProvider(data.providers[0].id);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch AI providers:', error);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const toggleProvider = (providerId: string) => {
|
|
||||||
const enabled = enabledProviders();
|
|
||||||
if (enabled.includes(providerId)) {
|
|
||||||
// Remove provider if it's currently selected, select another
|
|
||||||
if (selectedProvider() === providerId) {
|
|
||||||
const remaining = enabled.filter(p => p !== providerId);
|
|
||||||
setSelectedProvider(remaining.length > 0 ? remaining[0] : '');
|
|
||||||
}
|
|
||||||
setEnabledProviders(enabled.filter(p => p !== providerId));
|
|
||||||
} else {
|
|
||||||
setEnabledProviders([...enabled, providerId]);
|
|
||||||
// If this is the first provider, select it
|
|
||||||
if (enabled.length === 0) {
|
|
||||||
setSelectedProvider(providerId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -81,30 +53,20 @@ export const AIAssistant = () => {
|
|||||||
Leverage AI to enhance your productivity and content management
|
Leverage AI to enhance your productivity and content management
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{enabledProviders().length > 0 && (
|
{aiModels().length > 0 && (
|
||||||
<div class="flex items-center gap-3 text-sm">
|
<div class="flex items-center gap-3 text-sm">
|
||||||
<span class="text-gray-500">Active:</span>
|
<span class="text-gray-500">Active:</span>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
{enabledProviders().map(providerId => {
|
|
||||||
const provider = providers().find(p => p.id === providerId);
|
|
||||||
return (
|
|
||||||
<div class="flex items-center gap-1 px-2 py-1 bg-blue-50 dark:bg-blue-900/20 rounded-md">
|
<div class="flex items-center gap-1 px-2 py-1 bg-blue-50 dark:bg-blue-900/20 rounded-md">
|
||||||
<AIProviderIcon
|
<AIProviderIcon
|
||||||
providerId={providerId}
|
providerId={aiModels().find(m => m.id === selectedModel())?.iconId || 'longcat'}
|
||||||
size="1.25rem"
|
size="1.25rem"
|
||||||
class="text-primary"
|
class="text-primary"
|
||||||
/>
|
/>
|
||||||
<span class="font-medium text-blue-600 dark:text-blue-400">
|
<span class="font-medium text-blue-600 dark:text-blue-400">
|
||||||
{provider?.name || providerId}
|
{aiModels().find(m => m.id === selectedModel())?.name?.split(' ')[0] || 'AI'}
|
||||||
</span>
|
</span>
|
||||||
{selectedModel() !== 'standard' && selectedProvider() === providerId && (
|
|
||||||
<span class="text-xs text-blue-500">
|
|
||||||
{provider?.models.find(m => m.id === selectedModel())?.name?.split('-')[0]}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -135,16 +97,14 @@ export const AIAssistant = () => {
|
|||||||
<Card class="p-6">
|
<Card class="p-6">
|
||||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">AI Provider Settings</h3>
|
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">AI Provider Settings</h3>
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
{/* Provider Toggles */}
|
{/* AI Models */}
|
||||||
<div>
|
<div>
|
||||||
<h4 class="text-md font-medium text-gray-800 dark:text-gray-200 mb-3">Available Providers</h4>
|
<h4 class="text-md font-medium text-gray-800 dark:text-gray-200 mb-3">Available AI Models</h4>
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
{providers().map((provider) => {
|
{aiModels().map((model) => (
|
||||||
const isEnabled = enabledProviders().includes(provider.id);
|
|
||||||
return (
|
|
||||||
<div
|
<div
|
||||||
class={`p-4 border rounded-lg transition-all ${
|
class={`p-4 border rounded-lg transition-all ${
|
||||||
isEnabled
|
selectedModel() === model.id
|
||||||
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
||||||
: 'border-gray-200 dark:border-gray-700'
|
: 'border-gray-200 dark:border-gray-700'
|
||||||
}`}
|
}`}
|
||||||
@@ -152,99 +112,68 @@ export const AIAssistant = () => {
|
|||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<AIProviderIcon
|
<AIProviderIcon
|
||||||
providerId={provider.id}
|
providerId={model.iconId!}
|
||||||
size="2rem"
|
size="2rem"
|
||||||
class="text-primary"
|
class="text-primary"
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<h5 class="font-medium text-gray-900 dark:text-white">{provider.name}</h5>
|
<h5 class="font-medium text-gray-900 dark:text-white">{model.name}</h5>
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400">{provider.description}</p>
|
<p class="text-sm text-gray-600 dark:text-gray-400">{model.description}</p>
|
||||||
|
<div class="flex items-center gap-2 mt-2">
|
||||||
|
<span class="text-xs px-2 py-1 bg-blue-100 text-blue-800 rounded-full">
|
||||||
|
{model.provider}
|
||||||
|
</span>
|
||||||
|
<span class={`text-xs px-2 py-1 rounded-full ${
|
||||||
|
model.category === 'thinking'
|
||||||
|
? 'bg-purple-100 text-purple-800'
|
||||||
|
: model.category === 'fast'
|
||||||
|
? 'bg-green-100 text-green-800'
|
||||||
|
: model.category === 'advanced'
|
||||||
|
? 'bg-blue-100 text-blue-800'
|
||||||
|
: 'bg-gray-100 text-gray-800'
|
||||||
|
}`}>
|
||||||
|
{model.category}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleProvider(provider.id)}
|
onClick={() => setSelectedModel(model.id)}
|
||||||
class={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
class={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||||
isEnabled
|
selectedModel() === model.id
|
||||||
? 'bg-blue-600'
|
? 'bg-blue-600 text-white'
|
||||||
: 'bg-gray-200 dark:bg-gray-700'
|
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span
|
{selectedModel() === model.id ? 'Selected' : 'Select'}
|
||||||
class={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
|
||||||
isEnabled ? 'translate-x-6' : 'translate-x-1'
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Model selection for enabled providers */}
|
|
||||||
{isEnabled && (
|
|
||||||
<div class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
|
||||||
<div class="flex items-center gap-2 mb-2">
|
|
||||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
||||||
Model:
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={selectedProvider() === provider.id ? selectedModel() : 'standard'}
|
|
||||||
onChange={(e) => {
|
|
||||||
setSelectedProvider(provider.id);
|
|
||||||
setSelectedModel(e.target.value);
|
|
||||||
}}
|
|
||||||
class="text-sm px-2 py-1 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600"
|
|
||||||
>
|
|
||||||
{provider.models.map((model) => (
|
|
||||||
<option value={model.id}>
|
|
||||||
{model.type} - {model.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Model badges */}
|
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
{provider.models.map((model) => (
|
|
||||||
<div
|
|
||||||
class={`px-2 py-1 text-xs rounded-full border ${
|
|
||||||
model.id.includes('thinking') || model.id.includes('reasoner')
|
|
||||||
? 'bg-purple-100 text-purple-800 border-purple-300 dark:bg-purple-900 dark:text-purple-200'
|
|
||||||
: 'bg-gray-100 text-gray-800 border-gray-300 dark:bg-gray-700 dark:text-gray-200'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{model.type}
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Current Selection */}
|
{/* Current Selection */}
|
||||||
{enabledProviders().length > 0 && (
|
|
||||||
<div>
|
<div>
|
||||||
<h4 class="text-md font-medium text-gray-800 dark:text-gray-200 mb-3">Current Selection</h4>
|
<h4 class="text-md font-medium text-gray-800 dark:text-gray-200 mb-3">Current Selection</h4>
|
||||||
<div class="p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
<div class="p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<AIProviderIcon
|
<AIProviderIcon
|
||||||
providerId={selectedProvider()}
|
providerId={aiModels().find(m => m.id === selectedModel())?.iconId || 'longcat'}
|
||||||
size="1.5rem"
|
size="1.5rem"
|
||||||
class="text-primary"
|
class="text-primary"
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<p class="font-medium text-gray-900 dark:text-white">
|
<p class="font-medium text-gray-900 dark:text-white">
|
||||||
{providers().find(p => p.id === selectedProvider())?.name}
|
{aiModels().find(m => m.id === selectedModel())?.name}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
{providers().find(p => p.id === selectedProvider())?.models.find(m => m.id === selectedModel())?.name}
|
{aiModels().find(m => m.id === selectedModel())?.description}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+251
-391
@@ -1,4 +1,4 @@
|
|||||||
import { createSignal, For, Show, onMount } from 'solid-js'
|
import { createSignal, For, Show, onMount, createEffect } from 'solid-js'
|
||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
import { Input } from '@/components/ui/Input'
|
import { Input } from '@/components/ui/Input'
|
||||||
import { Card } from '@/components/ui/Card'
|
import { Card } from '@/components/ui/Card'
|
||||||
@@ -6,20 +6,27 @@ import {
|
|||||||
MessageCircle,
|
MessageCircle,
|
||||||
Brain,
|
Brain,
|
||||||
Cog,
|
Cog,
|
||||||
Send
|
Send,
|
||||||
|
ChevronDown,
|
||||||
|
User,
|
||||||
|
Bot
|
||||||
} from 'lucide-solid'
|
} from 'lucide-solid'
|
||||||
import { AIProviderIcon } from '@/components/AIProviderIcon'
|
import { AIProviderIcon } from '@/components/AIProviderIcon'
|
||||||
|
|
||||||
interface AIProvider {
|
interface AIModel {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
description: string
|
description: string
|
||||||
icon: string
|
provider: string
|
||||||
models: {
|
category: string
|
||||||
|
iconId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Message {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
role: 'user' | 'assistant'
|
||||||
type: string
|
content: string
|
||||||
}[];
|
timestamp: Date
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AIChat = () => {
|
export const AIChat = () => {
|
||||||
@@ -27,65 +34,23 @@ export const AIChat = () => {
|
|||||||
const [isSidebarOpen, setIsSidebarOpen] = createSignal(true)
|
const [isSidebarOpen, setIsSidebarOpen] = createSignal(true)
|
||||||
|
|
||||||
// Chat state
|
// Chat state
|
||||||
const [messages, setMessages] = createSignal<any[]>([
|
const [messages, setMessages] = createSignal<Message[]>([
|
||||||
{
|
{
|
||||||
id: 1,
|
id: '1',
|
||||||
content: 'Hello! I\'m your AI assistant. How can I help you today?',
|
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
created_at: new Date().toISOString()
|
content: 'Hello! I\'m your AI assistant. How can I help you today?',
|
||||||
|
timestamp: new Date()
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
const [inputMessage, setInputMessage] = createSignal('')
|
const [inputMessage, setInputMessage] = createSignal('')
|
||||||
const [isLoading, setIsLoading] = createSignal(false)
|
const [isLoading, setIsLoading] = createSignal(false)
|
||||||
|
|
||||||
// AI Provider state
|
// AI Model state
|
||||||
const [selectedProvider, setSelectedProvider] = createSignal<string>('')
|
const [selectedModel, setSelectedModel] = createSignal<string>('longcat-flash-chat')
|
||||||
const [selectedModel, setSelectedModel] = createSignal<string>('standard')
|
const [showModelPicker, setShowModelPicker] = createSignal(false)
|
||||||
const [enabledProviders, setEnabledProviders] = createSignal<string[]>([])
|
const [aiModels, setAIModels] = createSignal<AIModel[]>([])
|
||||||
const [providers, setProviders] = createSignal<AIProvider[]>([])
|
|
||||||
|
|
||||||
// Per-user AI settings (mirrors /api/v1/auth/ai/settings)
|
// Initialize AI models
|
||||||
const [aiSettings, setAISettings] = createSignal({
|
|
||||||
mistral: { enabled: false, api_key: '', model: '', model_thinking: '' },
|
|
||||||
grok: { enabled: false, api_key: '', base_url: '', model: '', model_thinking: '' },
|
|
||||||
deepseek: { enabled: false, api_key: '', base_url: '', model: '', model_thinking: '' },
|
|
||||||
ollama: { enabled: false, base_url: '', model: '', model_thinking: '' },
|
|
||||||
longcat: { enabled: false, api_key: '', base_url: '', openai_endpoint: '', anthropic_endpoint: '', model: '', model_thinking: '', model_thinking_upgraded: '', format: 'openai' }
|
|
||||||
})
|
|
||||||
const [aiSettingsLoading, setAiSettingsLoading] = createSignal(false)
|
|
||||||
const [aiSettingsMessage, setAiSettingsMessage] = createSignal('')
|
|
||||||
|
|
||||||
const handleSendMessage = async () => {
|
|
||||||
const message = inputMessage().trim()
|
|
||||||
if (!message || isLoading()) return
|
|
||||||
|
|
||||||
// Add user message
|
|
||||||
const userMessage = {
|
|
||||||
id: Date.now(),
|
|
||||||
content: message,
|
|
||||||
role: 'user',
|
|
||||||
created_at: new Date().toISOString()
|
|
||||||
}
|
|
||||||
|
|
||||||
setMessages(prev => [...prev, userMessage])
|
|
||||||
setInputMessage('')
|
|
||||||
setIsLoading(true)
|
|
||||||
|
|
||||||
// Simulate AI response
|
|
||||||
setTimeout(() => {
|
|
||||||
const aiResponse = {
|
|
||||||
id: Date.now() + 1,
|
|
||||||
content: `I received your message: "${message}". This is a demo response from the AI assistant. In production, I would provide a helpful response based on the selected AI provider and model.`,
|
|
||||||
role: 'assistant',
|
|
||||||
created_at: new Date().toISOString()
|
|
||||||
}
|
|
||||||
setMessages(prev => [...prev, aiResponse])
|
|
||||||
setIsLoading(false)
|
|
||||||
}, 1000)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// Check mobile on mount
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const checkMobile = () => {
|
const checkMobile = () => {
|
||||||
if (window.innerWidth < 768) {
|
if (window.innerWidth < 768) {
|
||||||
@@ -96,121 +61,121 @@ export const AIChat = () => {
|
|||||||
checkMobile()
|
checkMobile()
|
||||||
window.addEventListener('resize', checkMobile)
|
window.addEventListener('resize', checkMobile)
|
||||||
|
|
||||||
// Fetch AI providers
|
// Initialize AI models
|
||||||
fetchAIProviders()
|
initializeAIModels()
|
||||||
// Load per-user AI provider settings
|
|
||||||
loadAISettings()
|
|
||||||
|
|
||||||
return () => window.removeEventListener('resize', checkMobile)
|
return () => window.removeEventListener('resize', checkMobile)
|
||||||
})
|
})
|
||||||
|
|
||||||
const fetchAIProviders = async () => {
|
const initializeAIModels = () => {
|
||||||
try {
|
const models: AIModel[] = [
|
||||||
const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:8080'
|
{ id: 'longcat-flash-chat', name: 'LongCat Flash Chat', description: 'Fast and efficient chat model', provider: 'longcat', category: 'fast', iconId: 'longcat' },
|
||||||
const response = await fetch(`${apiUrl}/api/v1/ai/providers`)
|
{ id: 'longcat-flash-thinking', name: 'LongCat Flash Thinking', description: 'Advanced reasoning model', provider: 'longcat', category: 'thinking', iconId: 'longcat' },
|
||||||
const data = await response.json()
|
{ id: 'mistral-small-latest', name: 'Mistral Small', description: 'Lightweight and fast', provider: 'mistral', category: 'standard', iconId: 'mistral' },
|
||||||
setProviders(data.providers || [])
|
{ id: 'mistral-large-latest', name: 'Mistral Large', description: 'Most capable model', provider: 'mistral', category: 'advanced', iconId: 'mistral' },
|
||||||
|
{ id: 'grok-standard', name: 'Grok Standard', description: 'Grok from X', provider: 'grok', category: 'standard', iconId: 'grok' },
|
||||||
const providerIds = (data.providers || []).map((p: AIProvider) => p.id)
|
{ id: 'deepseek-chat', name: 'DeepSeek Chat', description: 'DeepSeek chat model', provider: 'deepseek', category: 'standard', iconId: 'deepseek' },
|
||||||
setEnabledProviders(providerIds)
|
{ id: 'ollama-local', name: 'Ollama Local', description: 'Local Ollama model', provider: 'ollama', category: 'local', iconId: 'ollama' },
|
||||||
|
{ id: 'openrouter-auto', name: 'OpenRouter Auto', description: 'Router over many models', provider: 'openrouter', category: 'standard', iconId: 'openrouter' },
|
||||||
if (data.providers && data.providers.length > 0) {
|
|
||||||
setSelectedProvider(data.providers[0].id)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch AI providers:', error)
|
|
||||||
// Set mock providers for demo mode
|
|
||||||
const mockProviders: AIProvider[] = [
|
|
||||||
{
|
|
||||||
id: 'longcat',
|
|
||||||
name: 'LongCat AI',
|
|
||||||
description: 'Fast and efficient AI models',
|
|
||||||
icon: '🐱',
|
|
||||||
models: [
|
|
||||||
{ id: 'longcat-flash-chat', name: 'LongCat Flash Chat', type: 'chat' },
|
|
||||||
{ id: 'longcat-flash-thinking', name: 'LongCat Flash Thinking', type: 'thinking' }
|
|
||||||
]
|
]
|
||||||
},
|
setAIModels(models)
|
||||||
{
|
|
||||||
id: 'mistral',
|
|
||||||
name: 'Mistral AI',
|
|
||||||
description: 'Advanced language models',
|
|
||||||
icon: '🌊',
|
|
||||||
models: [
|
|
||||||
{ id: 'mistral-small-latest', name: 'Mistral Small', type: 'chat' },
|
|
||||||
{ id: 'mistral-large-latest', name: 'Mistral Large', type: 'chat' }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
setProviders(mockProviders)
|
|
||||||
setEnabledProviders(['longcat'])
|
|
||||||
setSelectedProvider('longcat')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadAISettings = async () => {
|
const handleSendMessage = async () => {
|
||||||
try {
|
const message = inputMessage().trim()
|
||||||
const token = localStorage.getItem('token')
|
if (!message || isLoading()) return
|
||||||
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/auth/ai/settings`, {
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if (response.ok) {
|
// Add user message
|
||||||
const data = await response.json()
|
const userMessage: Message = {
|
||||||
setAISettings(data)
|
id: Date.now().toString(),
|
||||||
}
|
content: message,
|
||||||
} catch (error) {
|
role: 'user',
|
||||||
console.error('Failed to load AI settings:', error)
|
timestamp: new Date()
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleUpdateAISettings = async () => {
|
setMessages(prev => [...prev, userMessage])
|
||||||
setAiSettingsLoading(true)
|
setInputMessage('')
|
||||||
setAiSettingsMessage('')
|
setIsLoading(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('token')
|
// Call AI API
|
||||||
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/auth/ai/settings`, {
|
const response = await callAIAPI(message, selectedModel())
|
||||||
method: 'PUT',
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify(aiSettings())
|
|
||||||
})
|
|
||||||
|
|
||||||
if (response.ok) {
|
const aiMessage: Message = {
|
||||||
setAiSettingsMessage('AI settings updated successfully!')
|
id: (Date.now() + 1).toString(),
|
||||||
await loadAISettings()
|
role: 'assistant',
|
||||||
} else {
|
content: response,
|
||||||
const error = await response.json()
|
timestamp: new Date()
|
||||||
setAiSettingsMessage(error.error || 'Failed to update AI settings')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setMessages(prev => [...prev, aiMessage])
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to update AI settings:', error)
|
console.error('AI API call failed:', error)
|
||||||
setAiSettingsMessage('Failed to update AI settings')
|
|
||||||
|
// Fallback response
|
||||||
|
const errorMessage: Message = {
|
||||||
|
id: (Date.now() + 1).toString(),
|
||||||
|
role: 'assistant',
|
||||||
|
content: 'I apologize, but I encountered an error while processing your request. Please try again later.',
|
||||||
|
timestamp: new Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
setMessages(prev => [...prev, errorMessage])
|
||||||
} finally {
|
} finally {
|
||||||
setAiSettingsLoading(false)
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleProvider = (providerId: string) => {
|
const callAIAPI = async (message: string, modelId: string): Promise<string> => {
|
||||||
const enabled = enabledProviders()
|
const token = localStorage.getItem('token')
|
||||||
if (enabled.includes(providerId)) {
|
const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:8080'
|
||||||
if (selectedProvider() === providerId) {
|
|
||||||
const remaining = enabled.filter(p => p !== providerId)
|
const response = await fetch(`${apiUrl}/api/v1/ai/chat`, {
|
||||||
setSelectedProvider(remaining.length > 0 ? remaining[0] : '')
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': token ? `Bearer ${token}` : '',
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
message,
|
||||||
|
model: modelId,
|
||||||
|
stream: false
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`API call failed: ${response.status}`)
|
||||||
}
|
}
|
||||||
setEnabledProviders(enabled.filter(p => p !== providerId))
|
|
||||||
} else {
|
const data = await response.json()
|
||||||
setEnabledProviders([...enabled, providerId])
|
return data.response || data.content || 'I understand your message. Let me help you with that.'
|
||||||
if (enabled.length === 0) {
|
}
|
||||||
setSelectedProvider(providerId)
|
|
||||||
|
|
||||||
|
// Close model picker when clicking outside
|
||||||
|
createEffect(() => {
|
||||||
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
|
const target = e.target as HTMLElement
|
||||||
|
if (!target.closest('#model-picker-container')) {
|
||||||
|
setShowModelPicker(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showModelPicker()) {
|
||||||
|
document.addEventListener('click', handleClickOutside)
|
||||||
|
return () => document.removeEventListener('click', handleClickOutside)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
const startNewChat = () => {
|
||||||
|
setMessages([{
|
||||||
|
id: '1',
|
||||||
|
role: 'assistant',
|
||||||
|
content: 'Hello! I\'m your AI assistant. How can I help you today?',
|
||||||
|
timestamp: new Date()
|
||||||
|
}])
|
||||||
|
setInputMessage('')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -231,8 +196,8 @@ export const AIChat = () => {
|
|||||||
|
|
||||||
{/* AI Logo */}
|
{/* AI Logo */}
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<div class="w-8 h-8 bg-gradient-to-br from-blue-500 to-purple-600 rounded-lg flex items-center justify-center">
|
<div class="w-8 h-8 bg-muted rounded-lg flex items-center justify-center">
|
||||||
<Brain class="w-5 h-5 text-white" />
|
<Brain class="w-5 h-5 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<h1 class="font-semibold text-lg">AI Assistant</h1>
|
<h1 class="font-semibold text-lg">AI Assistant</h1>
|
||||||
@@ -304,15 +269,7 @@ export const AIChat = () => {
|
|||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
{/* New Chat Button */}
|
{/* New Chat Button */}
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={startNewChat}
|
||||||
setMessages([{
|
|
||||||
id: 1,
|
|
||||||
content: 'Hello! I\'m your AI assistant. How can I help you today?',
|
|
||||||
role: 'assistant',
|
|
||||||
created_at: new Date().toISOString()
|
|
||||||
}])
|
|
||||||
setInputMessage('')
|
|
||||||
}}
|
|
||||||
class="w-full justify-start"
|
class="w-full justify-start"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
>
|
>
|
||||||
@@ -334,10 +291,10 @@ export const AIChat = () => {
|
|||||||
class="w-full text-left p-3 rounded-lg hover:bg-muted transition-colors"
|
class="w-full text-left p-3 rounded-lg hover:bg-muted transition-colors"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setMessages([{
|
setMessages([{
|
||||||
id: 1,
|
id: '1',
|
||||||
content: `This is the ${session.title} session. How can I help you?`,
|
content: `This is the ${session.title} session. How can I help you?`,
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
created_at: new Date().toISOString()
|
timestamp: new Date()
|
||||||
}])
|
}])
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -384,13 +341,16 @@ export const AIChat = () => {
|
|||||||
message.role === 'user' ? 'bg-primary-foreground/20' : 'bg-primary/10'
|
message.role === 'user' ? 'bg-primary-foreground/20' : 'bg-primary/10'
|
||||||
}`}>
|
}`}>
|
||||||
{message.role === 'user' ? (
|
{message.role === 'user' ? (
|
||||||
<span class="text-xs">👤</span>
|
<User class="text-xs" />
|
||||||
) : (
|
) : (
|
||||||
<span class="text-xs">🤖</span>
|
<Bot class="text-xs" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<p class="text-sm leading-relaxed whitespace-pre-wrap break-words">{message.content}</p>
|
<p class="text-sm leading-relaxed whitespace-pre-wrap break-words">{message.content}</p>
|
||||||
|
<p class="text-xs opacity-70 mt-2">
|
||||||
|
{message.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -403,7 +363,7 @@ export const AIChat = () => {
|
|||||||
<div class="bg-muted rounded-lg p-4 max-w-[80%]">
|
<div class="bg-muted rounded-lg p-4 max-w-[80%]">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div class="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center">
|
<div class="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center">
|
||||||
<span class="text-xs">🤖</span>
|
<Bot class="text-xs" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-1">
|
<div class="flex gap-1">
|
||||||
<div class="w-2 h-2 bg-primary rounded-full animate-bounce"></div>
|
<div class="w-2 h-2 bg-primary rounded-full animate-bounce"></div>
|
||||||
@@ -422,6 +382,81 @@ export const AIChat = () => {
|
|||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<div class="max-w-4xl mx-auto">
|
<div class="max-w-4xl mx-auto">
|
||||||
<div class="flex gap-4">
|
<div class="flex gap-4">
|
||||||
|
{/* AI Model Switcher */}
|
||||||
|
<div id="model-picker-container" class="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowModelPicker(!showModelPicker())}
|
||||||
|
class="flex items-center gap-2 px-3 py-2 bg-muted hover:bg-muted/80 rounded-lg text-sm transition-colors border border-border/50"
|
||||||
|
>
|
||||||
|
<AIProviderIcon
|
||||||
|
providerId={aiModels().find(m => m.id === selectedModel())?.iconId || 'longcat'}
|
||||||
|
size="1rem"
|
||||||
|
/>
|
||||||
|
<span class="text-sm font-medium">
|
||||||
|
{aiModels().find(m => m.id === selectedModel())?.name?.split(' ')[0] || 'AI'}
|
||||||
|
</span>
|
||||||
|
<ChevronDown class={`h-4 w-4 transition-transform ${showModelPicker() ? 'rotate-180' : ''}`} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Model Picker Dropdown */}
|
||||||
|
<Show when={showModelPicker()}>
|
||||||
|
<div class="absolute bottom-full left-0 mb-2 w-80 bg-background border rounded-lg shadow-lg z-50 p-2 max-h-96 overflow-y-auto">
|
||||||
|
<div class="p-2 border-b mb-2">
|
||||||
|
<h4 class="text-sm font-semibold text-foreground">Select AI Model</h4>
|
||||||
|
<p class="text-xs text-muted-foreground">Choose the best model for your needs</p>
|
||||||
|
</div>
|
||||||
|
<For each={aiModels()}>
|
||||||
|
{model => (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedModel(model.id)
|
||||||
|
setShowModelPicker(false)
|
||||||
|
}}
|
||||||
|
class={`w-full text-left p-3 rounded-lg transition-colors ${
|
||||||
|
selectedModel() === model.id
|
||||||
|
? 'bg-primary/10 border border-primary/20'
|
||||||
|
: 'hover:bg-muted'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-3 flex-1">
|
||||||
|
<AIProviderIcon
|
||||||
|
providerId={model.iconId!}
|
||||||
|
size="1rem"
|
||||||
|
class="rounded-full flex-shrink-0"
|
||||||
|
/>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="font-medium text-sm truncate">{model.name}</div>
|
||||||
|
<div class="text-xs text-muted-foreground mt-1 truncate">{model.description}</div>
|
||||||
|
<div class="flex items-center gap-2 mt-2">
|
||||||
|
<span class="text-xs px-2 py-1 bg-primary/10 text-primary rounded-full">
|
||||||
|
{model.provider}
|
||||||
|
</span>
|
||||||
|
<span class={`text-xs px-2 py-1 rounded-full ${
|
||||||
|
model.category === 'thinking'
|
||||||
|
? 'bg-purple-100 text-purple-800'
|
||||||
|
: model.category === 'fast'
|
||||||
|
? 'bg-green-100 text-green-800'
|
||||||
|
: model.category === 'advanced'
|
||||||
|
? 'bg-blue-100 text-blue-800'
|
||||||
|
: 'bg-muted text-muted-foreground'
|
||||||
|
}`}>
|
||||||
|
{model.category}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{selectedModel() === model.id && (
|
||||||
|
<div class="w-2 h-2 bg-primary rounded-full flex-shrink-0"></div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
value={inputMessage()}
|
value={inputMessage()}
|
||||||
onInput={(e) => setInputMessage((e.currentTarget as HTMLInputElement).value)}
|
onInput={(e) => setInputMessage((e.currentTarget as HTMLInputElement).value)}
|
||||||
@@ -448,261 +483,86 @@ export const AIChat = () => {
|
|||||||
|
|
||||||
{/* Settings View */}
|
{/* Settings View */}
|
||||||
<Show when={activeView() === 'settings'}>
|
<Show when={activeView() === 'settings'}>
|
||||||
<div class="flex-1 overflow-y-auto p-2">
|
<div class="flex-1 overflow-y-auto p-6">
|
||||||
<div class="max-w-4xl mx-auto">
|
<div class="max-w-4xl mx-auto">
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<h2 class="text-2xl font-bold mb-2">AI Settings</h2>
|
<h2 class="text-2xl font-bold mb-2">AI Settings</h2>
|
||||||
<p class="text-muted-foreground">Configure your AI providers and preferences</p>
|
<p class="text-muted-foreground">Configure your AI models and preferences</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card class="p-6">
|
<Card class="p-6">
|
||||||
<h3 class="text-lg font-semibold mb-4">AI Provider Settings</h3>
|
<h3 class="text-lg font-semibold mb-4">Available AI Models</h3>
|
||||||
<div class="space-y-6">
|
<div class="space-y-4">
|
||||||
{/* Provider Toggles */}
|
<For each={aiModels()}>
|
||||||
<div>
|
{(model) => (
|
||||||
<h4 class="text-md font-medium mb-3">Available Providers</h4>
|
|
||||||
<div class="space-y-3">
|
|
||||||
<For each={providers()}>
|
|
||||||
{(provider) => {
|
|
||||||
const isEnabled = enabledProviders().includes(provider.id)
|
|
||||||
return (
|
|
||||||
<div
|
<div
|
||||||
class={`p-4 border rounded-lg transition-all ${
|
class={`p-4 border rounded-lg transition-all ${
|
||||||
isEnabled
|
selectedModel() === model.id
|
||||||
? 'border-primary bg-primary/5'
|
? 'border-primary bg-primary/5'
|
||||||
: 'border-border'
|
: 'border-border hover:bg-muted/50'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<AIProviderIcon
|
<AIProviderIcon
|
||||||
providerId={provider.id}
|
providerId={model.iconId!}
|
||||||
size="2rem"
|
size="2rem"
|
||||||
class="text-primary"
|
class="text-primary"
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<h5 class="font-medium">{provider.name}</h5>
|
<h5 class="font-medium">{model.name}</h5>
|
||||||
<p class="text-sm text-muted-foreground">{provider.description}</p>
|
<p class="text-sm text-muted-foreground">{model.description}</p>
|
||||||
|
<div class="flex items-center gap-2 mt-2">
|
||||||
|
<span class="text-xs px-2 py-1 bg-primary/10 text-primary rounded-full">
|
||||||
|
{model.provider}
|
||||||
|
</span>
|
||||||
|
<span class={`text-xs px-2 py-1 rounded-full ${
|
||||||
|
model.category === 'thinking'
|
||||||
|
? 'bg-purple-100 text-purple-800'
|
||||||
|
: model.category === 'fast'
|
||||||
|
? 'bg-green-100 text-green-800'
|
||||||
|
: model.category === 'advanced'
|
||||||
|
? 'bg-blue-100 text-blue-800'
|
||||||
|
: 'bg-muted text-muted-foreground'
|
||||||
|
}`}>
|
||||||
|
{model.category}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleProvider(provider.id)}
|
onClick={() => setSelectedModel(model.id)}
|
||||||
class={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
class={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||||
isEnabled
|
selectedModel() === model.id
|
||||||
? 'bg-primary'
|
? 'bg-primary text-primary-foreground'
|
||||||
: 'bg-muted'
|
: 'bg-muted hover:bg-muted/80'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span
|
{selectedModel() === model.id ? 'Selected' : 'Select'}
|
||||||
class={`inline-block h-4 w-4 transform rounded-full bg-background transition-transform ${
|
|
||||||
isEnabled ? 'translate-x-6' : 'translate-x-1'
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Model selection */}
|
|
||||||
{isEnabled && (
|
|
||||||
<div class="mt-4 pt-4 border-t border-border">
|
|
||||||
<div class="flex items-center gap-2 mb-2">
|
|
||||||
<label class="text-sm font-medium">
|
|
||||||
Model:
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={selectedProvider() === provider.id ? selectedModel() : 'standard'}
|
|
||||||
onChange={(e) => {
|
|
||||||
setSelectedProvider(provider.id)
|
|
||||||
setSelectedModel(e.target.value)
|
|
||||||
}}
|
|
||||||
class="text-sm px-2 py-1 border border-border rounded focus:outline-none focus:ring-2 focus:ring-primary"
|
|
||||||
>
|
|
||||||
<For each={provider.models}>
|
|
||||||
{(model) => (
|
|
||||||
<option value={model.id}>
|
|
||||||
{model.type} - {model.name}
|
|
||||||
</option>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</For>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Card>
|
||||||
|
|
||||||
{/* Response Settings */}
|
<Card class="p-6 mt-6">
|
||||||
<div>
|
<h3 class="text-lg font-semibold mb-4">Current Selection</h3>
|
||||||
<h4 class="text-md font-medium mb-3">Response Settings</h4>
|
<div class="p-4 bg-muted/50 rounded-lg">
|
||||||
<div class="space-y-4">
|
<div class="flex items-center gap-3">
|
||||||
<div class="p-4 border border-border rounded-lg">
|
<AIProviderIcon
|
||||||
<label class="block text-sm font-medium mb-2">Response Length</label>
|
providerId={aiModels().find(m => m.id === selectedModel())?.iconId || 'longcat'}
|
||||||
<select class="w-full text-sm px-3 py-2 border border-border rounded focus:outline-none focus:ring-2 focus:ring-primary">
|
size="1.5rem"
|
||||||
<option value="concise">Concise</option>
|
class="text-primary"
|
||||||
<option value="balanced" selected>Balanced</option>
|
|
||||||
<option value="detailed">Detailed</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="p-4 border border-border rounded-lg">
|
|
||||||
<label class="block text-sm font-medium mb-2">Response Style</label>
|
|
||||||
<select class="w-full text-sm px-3 py-2 border border-border rounded focus:outline-none focus:ring-2 focus:ring-primary">
|
|
||||||
<option value="professional" selected>Professional</option>
|
|
||||||
<option value="casual">Casual</option>
|
|
||||||
<option value="technical">Technical</option>
|
|
||||||
<option value="creative">Creative</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Account-level provider settings (example: LongCat) */}
|
|
||||||
<div class="pt-4 mt-2 border-t border-border space-y-4">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h4 class="text-md font-medium">Account Provider Settings</h4>
|
|
||||||
<span class="text-xs text-muted-foreground">{aiSettingsMessage()}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="border rounded-lg p-4 space-y-3">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span class="w-2 h-2 bg-purple-500 rounded-full" />
|
|
||||||
<span class="text-sm font-medium">LongCat AI</span>
|
|
||||||
</div>
|
|
||||||
<label class="flex items-center gap-2 text-xs cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={aiSettings().longcat.enabled}
|
|
||||||
onChange={(e) => {
|
|
||||||
const settings = aiSettings()
|
|
||||||
setAISettings({
|
|
||||||
...settings,
|
|
||||||
longcat: { ...settings.longcat, enabled: e.currentTarget.checked }
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
class="rounded border-input"
|
|
||||||
/>
|
/>
|
||||||
<span>Enabled</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-medium text-muted-foreground mb-1">API Key</label>
|
<p class="font-medium">
|
||||||
<input
|
{aiModels().find(m => m.id === selectedModel())?.name}
|
||||||
type="password"
|
</p>
|
||||||
value={aiSettings().longcat.api_key}
|
<p class="text-sm text-muted-foreground">
|
||||||
onInput={(e) => {
|
{aiModels().find(m => m.id === selectedModel())?.description}
|
||||||
const settings = aiSettings()
|
</p>
|
||||||
setAISettings({
|
|
||||||
...settings,
|
|
||||||
longcat: { ...settings.longcat, api_key: e.currentTarget.value }
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
placeholder="LongCat API key"
|
|
||||||
class="flex h-9 w-full rounded-md border border-input bg-background px-2 py-1.5 text-xs text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs font-medium text-muted-foreground mb-1">Base URL</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={aiSettings().longcat.base_url}
|
|
||||||
onInput={(e) => {
|
|
||||||
const settings = aiSettings()
|
|
||||||
setAISettings({
|
|
||||||
...settings,
|
|
||||||
longcat: { ...settings.longcat, base_url: e.currentTarget.value }
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
class="flex h-9 w-full rounded-md border border-input bg-background px-2 py-1.5 text-xs text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs font-medium text-muted-foreground mb-1">Chat Model</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={aiSettings().longcat.model}
|
|
||||||
onInput={(e) => {
|
|
||||||
const settings = aiSettings()
|
|
||||||
setAISettings({
|
|
||||||
...settings,
|
|
||||||
longcat: { ...settings.longcat, model: e.currentTarget.value }
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
class="flex h-9 w-full rounded-md border border-input bg-background px-2 py-1.5 text-xs text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs font-medium text-muted-foreground mb-1">Thinking Model</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={aiSettings().longcat.model_thinking}
|
|
||||||
onInput={(e) => {
|
|
||||||
const settings = aiSettings()
|
|
||||||
setAISettings({
|
|
||||||
...settings,
|
|
||||||
longcat: { ...settings.longcat, model_thinking: e.currentTarget.value }
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
class="flex h-9 w-full rounded-md border border-input bg-background px-2 py-1.5 text-xs text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs font-medium text-muted-foreground mb-1">Upgraded Thinking</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={aiSettings().longcat.model_thinking_upgraded}
|
|
||||||
onInput={(e) => {
|
|
||||||
const settings = aiSettings()
|
|
||||||
setAISettings({
|
|
||||||
...settings,
|
|
||||||
longcat: { ...settings.longcat, model_thinking_upgraded: e.currentTarget.value }
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
class="flex h-9 w-full rounded-md border border-input bg-background px-2 py-1.5 text-xs text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs font-medium text-muted-foreground mb-1">Format</label>
|
|
||||||
<select
|
|
||||||
value={aiSettings().longcat.format}
|
|
||||||
onChange={(e) => {
|
|
||||||
const settings = aiSettings()
|
|
||||||
setAISettings({
|
|
||||||
...settings,
|
|
||||||
longcat: { ...settings.longcat, format: e.currentTarget.value as 'openai' | 'anthropic' }
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
class="flex h-9 w-full rounded-md border border-input bg-background px-2 py-1.5 text-xs text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
|
||||||
>
|
|
||||||
<option value="openai">OpenAI Compatible</option>
|
|
||||||
<option value="anthropic">Anthropic Compatible</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-3 pt-2">
|
|
||||||
<Button
|
|
||||||
onClick={handleUpdateAISettings}
|
|
||||||
disabled={aiSettingsLoading()}
|
|
||||||
>
|
|
||||||
{aiSettingsLoading() ? 'Saving...' : 'Save AI Settings'}
|
|
||||||
</Button>
|
|
||||||
<a
|
|
||||||
href="/app/settings"
|
|
||||||
class="ml-auto text-xs text-primary hover:underline"
|
|
||||||
>
|
|
||||||
Open full AI settings
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ import { Card } from '@/components/ui/Card';
|
|||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { BookmarkModal } from '@/components/ui/BookmarkModal';
|
import { BookmarkModal } from '@/components/ui/BookmarkModal';
|
||||||
import { EditBookmarkModal } from '@/components/ui/EditBookmarkModal';
|
import { EditBookmarkModal } from '@/components/ui/EditBookmarkModal';
|
||||||
|
import { VideoUploadModal } from '@/components/ui/VideoUploadModal';
|
||||||
import { DropdownMenu, DropdownMenuItem } from '@/components/ui/DropdownMenu';
|
import { DropdownMenu, DropdownMenuItem } from '@/components/ui/DropdownMenu';
|
||||||
import { SearchTagFilterBar } from '@/components/ui/SearchTagFilterBar';
|
import { SearchTagFilterBar } from '@/components/ui/SearchTagFilterBar';
|
||||||
import { IconDotsVertical, IconStar, IconEdit, IconTrash, IconExternalLink, IconVideo } from '@tabler/icons-solidjs';
|
import { IconDotsVertical, IconStar, IconEdit, IconTrash, IconExternalLink, IconVideo, IconBookmark } from '@tabler/icons-solidjs';
|
||||||
import { getMockBookmarks, getMockVideos } from '@/lib/mockData';
|
import { getMockBookmarks, getMockVideos } from '@/lib/mockData';
|
||||||
|
|
||||||
interface BookmarkTag {
|
interface BookmarkTag {
|
||||||
@@ -65,7 +66,21 @@ export const Bookmarks = () => {
|
|||||||
if (bookmark.favicon) return bookmark.favicon;
|
if (bookmark.favicon) return bookmark.favicon;
|
||||||
try {
|
try {
|
||||||
const url = new URL(bookmark.url);
|
const url = new URL(bookmark.url);
|
||||||
return `https://www.google.com/s2/favicons?domain=${url.hostname}&sz=64`;
|
const baseUrl = `${url.protocol}//${url.hostname}`;
|
||||||
|
|
||||||
|
// Try multiple favicon sources
|
||||||
|
const faviconSources = [
|
||||||
|
`${baseUrl}/favicon.ico`,
|
||||||
|
`${baseUrl}/favicon.png`,
|
||||||
|
`${baseUrl}/img/favicons/favicon-32x32.png`,
|
||||||
|
`${baseUrl}/img/favicons/favicon-16x16.png`,
|
||||||
|
`${baseUrl}/logo-without-border.svg`,
|
||||||
|
`${baseUrl}/logo.svg`,
|
||||||
|
`${baseUrl}/icon.svg`,
|
||||||
|
`https://www.google.com/s2/favicons?domain=${url.hostname}&sz=64`
|
||||||
|
];
|
||||||
|
|
||||||
|
return faviconSources[0]; // Return first source, fallback will be handled by error
|
||||||
} catch {
|
} catch {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
@@ -88,8 +103,11 @@ export const Bookmarks = () => {
|
|||||||
const [isLoadingVideos, setIsLoadingVideos] = createSignal(true);
|
const [isLoadingVideos, setIsLoadingVideos] = createSignal(true);
|
||||||
const [searchTerm, setSearchTerm] = createSignal('');
|
const [searchTerm, setSearchTerm] = createSignal('');
|
||||||
const [selectedTag, setSelectedTag] = createSignal('');
|
const [selectedTag, setSelectedTag] = createSignal('');
|
||||||
|
const [videoSearchTerm, setVideoSearchTerm] = createSignal('');
|
||||||
|
const [videoSelectedTag, setVideoSelectedTag] = createSignal('');
|
||||||
const [showAddModal, setShowAddModal] = createSignal(false);
|
const [showAddModal, setShowAddModal] = createSignal(false);
|
||||||
const [showEditModal, setShowEditModal] = createSignal(false);
|
const [showEditModal, setShowEditModal] = createSignal(false);
|
||||||
|
const [showVideoModal, setShowVideoModal] = createSignal(false);
|
||||||
const [editingBookmark, setEditingBookmark] = createSignal<Bookmark | null>(null);
|
const [editingBookmark, setEditingBookmark] = createSignal<Bookmark | null>(null);
|
||||||
const [activeTab, setActiveTab] = createSignal<'bookmarks' | 'videos'>('bookmarks');
|
const [activeTab, setActiveTab] = createSignal<'bookmarks' | 'videos'>('bookmarks');
|
||||||
// We no longer show inline HTML content previews, only the bookmark cards themselves
|
// We no longer show inline HTML content previews, only the bookmark cards themselves
|
||||||
@@ -127,22 +145,48 @@ export const Bookmarks = () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8081/api/v1';
|
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8081/api/v1';
|
||||||
const response = await fetch(`${API_BASE_URL}/bookmarks`, {
|
|
||||||
|
// Load regular bookmarks
|
||||||
|
const bookmarksResponse = await fetch(`${API_BASE_URL}/bookmarks`, {
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': localStorage.getItem('trackeep_token') ? `Bearer ${localStorage.getItem('trackeep_token')}` : '',
|
'Authorization': localStorage.getItem('trackeep_token') ? `Bearer ${localStorage.getItem('trackeep_token')}` : '',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!bookmarksResponse.ok) {
|
||||||
throw new Error('Failed to load bookmarks');
|
throw new Error('Failed to load bookmarks');
|
||||||
}
|
}
|
||||||
const data = await response.json();
|
const bookmarksData = await bookmarksResponse.json();
|
||||||
|
|
||||||
// Normalize API response:
|
// Normalize API response:
|
||||||
// - Ensure we always work with an array
|
// - Ensure we always work with an array
|
||||||
// - Map Tag objects to simple string[]
|
// - Map Tag objects to simple string[]
|
||||||
const normalized: Bookmark[] = (Array.isArray(data) ? data : []).map(adaptBookmarkFromApi);
|
const normalized: Bookmark[] = (Array.isArray(bookmarksData) ? bookmarksData : []).map(adaptBookmarkFromApi);
|
||||||
|
|
||||||
setBookmarks(normalized);
|
setBookmarks(normalized);
|
||||||
|
|
||||||
|
// Load video bookmarks
|
||||||
|
try {
|
||||||
|
const videosResponse = await fetch(`${API_BASE_URL}/youtube/videos`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': localStorage.getItem('trackeep_token') ? `Bearer ${localStorage.getItem('trackeep_token')}` : '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (videosResponse.ok) {
|
||||||
|
const videosData = await videosResponse.json();
|
||||||
|
setVideoBookmarks(Array.isArray(videosData) ? videosData : []);
|
||||||
|
} else {
|
||||||
|
// If video endpoint fails, load mock videos as fallback
|
||||||
|
const mockVideos = getMockVideos();
|
||||||
|
setVideoBookmarks(mockVideos);
|
||||||
|
}
|
||||||
|
} catch (videoError) {
|
||||||
|
console.warn('Failed to load video bookmarks, using mock data:', videoError);
|
||||||
|
const mockVideos = getMockVideos();
|
||||||
|
setVideoBookmarks(mockVideos);
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoadingVideos(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load bookmarks:', error);
|
console.error('Failed to load bookmarks:', error);
|
||||||
// Fallback to mock data if API fails
|
// Fallback to mock data if API fails
|
||||||
@@ -160,6 +204,11 @@ export const Bookmarks = () => {
|
|||||||
screenshot_medium: bookmark.screenshot,
|
screenshot_medium: bookmark.screenshot,
|
||||||
}));
|
}));
|
||||||
setBookmarks(adaptedBookmarks);
|
setBookmarks(adaptedBookmarks);
|
||||||
|
|
||||||
|
// Also load mock videos as fallback
|
||||||
|
const mockVideos = getMockVideos();
|
||||||
|
setVideoBookmarks(mockVideos);
|
||||||
|
setIsLoadingVideos(false);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
@@ -174,6 +223,15 @@ export const Bookmarks = () => {
|
|||||||
return Array.from(tags).sort();
|
return Array.from(tags).sort();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Get all unique tags from video bookmarks
|
||||||
|
const getAllVideoTags = () => {
|
||||||
|
const tags = new Set<string>();
|
||||||
|
videoBookmarks().forEach((video) => {
|
||||||
|
(video.tags || []).forEach((tag: any) => tags.add(tag.name));
|
||||||
|
});
|
||||||
|
return Array.from(tags).sort();
|
||||||
|
};
|
||||||
|
|
||||||
const filteredBookmarks = () => {
|
const filteredBookmarks = () => {
|
||||||
const term = searchTerm().toLowerCase();
|
const term = searchTerm().toLowerCase();
|
||||||
const tag = selectedTag();
|
const tag = selectedTag();
|
||||||
@@ -191,6 +249,23 @@ export const Bookmarks = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const filteredVideoBookmarks = () => {
|
||||||
|
const term = videoSearchTerm().toLowerCase();
|
||||||
|
const tag = videoSelectedTag();
|
||||||
|
|
||||||
|
return videoBookmarks().filter(video => {
|
||||||
|
const matchesSearch = !term ||
|
||||||
|
video.title.toLowerCase().includes(term) ||
|
||||||
|
video.description.toLowerCase().includes(term) ||
|
||||||
|
video.channel.toLowerCase().includes(term) ||
|
||||||
|
(video.tags || []).some((t: any) => t.name.toLowerCase().includes(term));
|
||||||
|
|
||||||
|
const matchesTag = !tag || (video.tags || []).some((t: any) => t.name === tag);
|
||||||
|
|
||||||
|
return matchesSearch && matchesTag;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// We no longer fetch or display full page metadata/content previews here.
|
// We no longer fetch or display full page metadata/content previews here.
|
||||||
|
|
||||||
const handleAddBookmark = async (bookmarkData: any) => {
|
const handleAddBookmark = async (bookmarkData: any) => {
|
||||||
@@ -262,11 +337,21 @@ export const Bookmarks = () => {
|
|||||||
setSearchTerm(''); // Clear search when filtering by tag
|
setSearchTerm(''); // Clear search when filtering by tag
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleVideoTagClick = (tag: string) => {
|
||||||
|
setVideoSelectedTag((current) => (current === tag ? '' : tag));
|
||||||
|
setVideoSearchTerm(''); // Clear search when filtering by tag
|
||||||
|
};
|
||||||
|
|
||||||
const resetFilters = () => {
|
const resetFilters = () => {
|
||||||
setSearchTerm('');
|
setSearchTerm('');
|
||||||
setSelectedTag('');
|
setSelectedTag('');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const resetVideoFilters = () => {
|
||||||
|
setVideoSearchTerm('');
|
||||||
|
setVideoSelectedTag('');
|
||||||
|
};
|
||||||
|
|
||||||
const handleEditBookmark = async (bookmarkData: Partial<Bookmark>) => {
|
const handleEditBookmark = async (bookmarkData: Partial<Bookmark>) => {
|
||||||
if (!editingBookmark()) return;
|
if (!editingBookmark()) return;
|
||||||
|
|
||||||
@@ -300,6 +385,30 @@ export const Bookmarks = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleVideoSubmit = async (video: any) => {
|
||||||
|
try {
|
||||||
|
// Use the YouTube API to add video
|
||||||
|
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/youtube/video-details`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ video_id: video.video_id })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
console.log('Video added:', video);
|
||||||
|
} else {
|
||||||
|
console.log('Video added (demo mode):', video);
|
||||||
|
}
|
||||||
|
setShowVideoModal(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to add video:', error);
|
||||||
|
setShowVideoModal(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="p-6 space-y-6">
|
<div class="p-6 space-y-6">
|
||||||
<div class="flex justify-between items-center">
|
<div class="flex justify-between items-center">
|
||||||
@@ -314,9 +423,18 @@ export const Bookmarks = () => {
|
|||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
<Show when={activeTab() === 'bookmarks'}>
|
||||||
<Button onClick={() => setShowAddModal(true)}>
|
<Button onClick={() => setShowAddModal(true)}>
|
||||||
|
<IconBookmark class="size-4 mr-2" />
|
||||||
Add Bookmark
|
Add Bookmark
|
||||||
</Button>
|
</Button>
|
||||||
|
</Show>
|
||||||
|
<Show when={activeTab() === 'videos'}>
|
||||||
|
<Button onClick={() => setShowVideoModal(true)}>
|
||||||
|
<IconVideo class="size-4 mr-2" />
|
||||||
|
Add Video
|
||||||
|
</Button>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
@@ -324,12 +442,13 @@ export const Bookmarks = () => {
|
|||||||
<nav class="-mb-px flex space-x-8">
|
<nav class="-mb-px flex space-x-8">
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('bookmarks')}
|
onClick={() => setActiveTab('bookmarks')}
|
||||||
class={`py-2 px-1 border-b-2 font-medium text-sm transition-colors ${
|
class={`py-2 px-1 border-b-2 font-medium text-sm transition-colors flex items-center gap-2 ${
|
||||||
activeTab() === 'bookmarks'
|
activeTab() === 'bookmarks'
|
||||||
? 'border-primary text-primary'
|
? 'border-primary text-primary'
|
||||||
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-muted'
|
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-muted'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
<IconBookmark class={`size-4 ${activeTab() === 'bookmarks' ? 'text-primary' : 'text-muted-foreground'}`} />
|
||||||
Web Bookmarks
|
Web Bookmarks
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@@ -340,7 +459,7 @@ export const Bookmarks = () => {
|
|||||||
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-muted'
|
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-muted'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<IconVideo class="size-4" />
|
<IconVideo class={`size-4 ${activeTab() === 'videos' ? 'text-primary' : 'text-muted-foreground'}`} />
|
||||||
Video Bookmarks
|
Video Bookmarks
|
||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -419,8 +538,31 @@ export const Bookmarks = () => {
|
|||||||
alt=""
|
alt=""
|
||||||
class="w-6 h-6 object-contain"
|
class="w-6 h-6 object-contain"
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
e.currentTarget.style.display = 'none';
|
const img = e.currentTarget;
|
||||||
e.currentTarget.parentElement!.innerHTML = `<span class="text-xs text-muted-foreground font-medium">${bookmark.title.charAt(0).toUpperCase()}</span>`;
|
const url = new URL(bookmark.url);
|
||||||
|
const baseUrl = `${url.protocol}//${url.hostname}`;
|
||||||
|
|
||||||
|
// Try next favicon source
|
||||||
|
const faviconSources = [
|
||||||
|
`${baseUrl}/favicon.ico`,
|
||||||
|
`${baseUrl}/favicon.png`,
|
||||||
|
`${baseUrl}/img/favicons/favicon-32x32.png`,
|
||||||
|
`${baseUrl}/img/favicons/favicon-16x16.png`,
|
||||||
|
`${baseUrl}/logo-without-border.svg`,
|
||||||
|
`${baseUrl}/logo.svg`,
|
||||||
|
`${baseUrl}/icon.svg`,
|
||||||
|
`https://www.google.com/s2/favicons?domain=${url.hostname}&sz=64`
|
||||||
|
];
|
||||||
|
|
||||||
|
const currentSrc = img.src;
|
||||||
|
const currentIndex = faviconSources.findIndex(src => currentSrc.includes(src));
|
||||||
|
|
||||||
|
if (currentIndex < faviconSources.length - 1) {
|
||||||
|
img.src = faviconSources[currentIndex + 1];
|
||||||
|
} else {
|
||||||
|
img.style.display = 'none';
|
||||||
|
img.parentElement!.innerHTML = `<span class="text-xs text-muted-foreground font-medium">${bookmark.title.charAt(0).toUpperCase()}</span>`;
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -532,6 +674,16 @@ export const Bookmarks = () => {
|
|||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={activeTab() === 'videos'}>
|
<Show when={activeTab() === 'videos'}>
|
||||||
|
<SearchTagFilterBar
|
||||||
|
searchPlaceholder="Search video bookmarks..."
|
||||||
|
searchValue={videoSearchTerm()}
|
||||||
|
onSearchChange={(value) => setVideoSearchTerm(value)}
|
||||||
|
tagOptions={getAllVideoTags()}
|
||||||
|
selectedTag={videoSelectedTag()}
|
||||||
|
onTagChange={(value) => setVideoSelectedTag(value)}
|
||||||
|
onReset={resetVideoFilters}
|
||||||
|
/>
|
||||||
|
|
||||||
{isLoadingVideos() ? (
|
{isLoadingVideos() ? (
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
{[...Array(3)].map(() => (
|
{[...Array(3)].map(() => (
|
||||||
@@ -546,9 +698,10 @@ export const Bookmarks = () => {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
{videoBookmarks().map((video) => (
|
{filteredVideoBookmarks().map((video) => (
|
||||||
<Card class="p-6 hover:bg-accent transition-colors">
|
<Card class="p-6 hover:bg-accent transition-colors group">
|
||||||
<div class="flex gap-4">
|
<div class="flex justify-between items-start gap-4">
|
||||||
|
<div class="flex gap-4 flex-1">
|
||||||
<div class="flex-shrink-0">
|
<div class="flex-shrink-0">
|
||||||
<img
|
<img
|
||||||
src={video.thumbnail}
|
src={video.thumbnail}
|
||||||
@@ -565,7 +718,7 @@ export const Bookmarks = () => {
|
|||||||
class="text-primary hover:text-primary/80 transition-colors flex items-center gap-1"
|
class="text-primary hover:text-primary/80 transition-colors flex items-center gap-1"
|
||||||
>
|
>
|
||||||
{video.title}
|
{video.title}
|
||||||
<IconExternalLink class="size-5 ml-1.5 flex-shrink-0 text-current" />
|
<IconExternalLink class="size-5 ml-1.5 flex-shrink-0 text-current group-hover:text-white" />
|
||||||
</a>
|
</a>
|
||||||
</h3>
|
</h3>
|
||||||
<p class="text-muted-foreground text-sm mb-2">{video.description}</p>
|
<p class="text-muted-foreground text-sm mb-2">{video.description}</p>
|
||||||
@@ -578,26 +731,75 @@ export const Bookmarks = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap gap-2 mt-2">
|
<div class="flex flex-wrap gap-2 mt-2">
|
||||||
{video.tags.map((tag: any) => (
|
{video.tags.map((tag: any) => (
|
||||||
<span class="px-2 py-1 text-xs rounded-md bg-muted text-muted-foreground">
|
<button
|
||||||
|
onClick={() => handleVideoTagClick(tag.name)}
|
||||||
|
class={`px-2 py-1 text-xs rounded-md border transition-colors cursor-pointer
|
||||||
|
${videoSelectedTag() === tag.name
|
||||||
|
? 'bg-primary text-primary-foreground border-primary'
|
||||||
|
: 'bg-muted/80 text-muted-foreground border-transparent group-hover:bg-accent group-hover:text-accent-foreground group-hover:border-border'
|
||||||
|
}`}
|
||||||
|
title={`Click to filter by ${tag.name}`}
|
||||||
|
>
|
||||||
{tag.name}
|
{tag.name}
|
||||||
</span>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex items-center gap-2 ml-2">
|
||||||
|
<DropdownMenu
|
||||||
|
trigger={
|
||||||
|
<button class="inline-flex items-center justify-center rounded-md text-sm font-medium transition-shadow focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-inherit hover:bg-accent/50 hover:text-accent-foreground h-8 w-8">
|
||||||
|
<IconDotsVertical class="size-4" />
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => window.open(video.url, '_blank')}
|
||||||
|
icon={IconExternalLink}
|
||||||
|
>
|
||||||
|
Open in New Tab
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => navigator.clipboard.writeText(video.url)}
|
||||||
|
icon={IconEdit}
|
||||||
|
>
|
||||||
|
Copy Link
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm('Are you sure you want to delete this video bookmark?')) {
|
||||||
|
setVideoBookmarks(prev => prev.filter(v => v.id !== video.id));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
icon={IconTrash}
|
||||||
|
variant="destructive"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{videoBookmarks().length === 0 && (
|
{filteredVideoBookmarks().length === 0 && (
|
||||||
<Card class="p-12 text-center">
|
<Card class="p-12 text-center">
|
||||||
<p class="text-muted-foreground">
|
<p class="text-muted-foreground">
|
||||||
No video bookmarks yet. Save your first YouTube video!
|
{videoSearchTerm() || videoSelectedTag() ? 'No video bookmarks found matching your search.' : 'No video bookmarks yet. Save your first YouTube video!'}
|
||||||
</p>
|
</p>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
|
{/* Video Upload Modal */}
|
||||||
|
<VideoUploadModal
|
||||||
|
isOpen={showVideoModal()}
|
||||||
|
onClose={() => setShowVideoModal(false)}
|
||||||
|
onSubmit={handleVideoSubmit}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createSignal, createEffect, onMount, For, Show } from 'solid-js'
|
import { createSignal, createEffect, onMount, For, Show } from 'solid-js'
|
||||||
import { DateTimePicker } from '@/components/ui/DatePicker';
|
import { DateRangePicker } from '@/components/ui/DateRangePicker';
|
||||||
import {
|
import {
|
||||||
IconCalendar,
|
IconCalendar,
|
||||||
IconClock,
|
IconClock,
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
IconFlag
|
IconFlag
|
||||||
} from '@tabler/icons-solidjs'
|
} from '@tabler/icons-solidjs'
|
||||||
import { getMockCalendarEvents } from '@/lib/mockData';
|
import { getMockCalendarEvents } from '@/lib/mockData';
|
||||||
|
import { isDemoMode as isDemoModeEnabled } from '@/lib/demo-mode';
|
||||||
|
|
||||||
interface CalendarEvent {
|
interface CalendarEvent {
|
||||||
id: number
|
id: number
|
||||||
@@ -80,21 +81,15 @@ export function Calendar() {
|
|||||||
return () => clearInterval(timer)
|
return () => clearInterval(timer)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Check if we're in demo mode
|
|
||||||
const isDemoMode = () => {
|
|
||||||
return localStorage.getItem('demoMode') === 'true' ||
|
|
||||||
document.title.includes('Demo Mode') ||
|
|
||||||
window.location.search.includes('demo=true');
|
|
||||||
};
|
|
||||||
|
|
||||||
// Fetch calendar data
|
// Fetch calendar data
|
||||||
const fetchCalendarData = async () => {
|
const fetchCalendarData = async () => {
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('token');
|
||||||
|
|
||||||
if (isDemoMode() || !token) {
|
if (isDemoModeEnabled() || !token) {
|
||||||
// Use mock data in demo mode or when not authenticated
|
// Use mock data in demo mode or when not authenticated
|
||||||
const mockEvents = getMockCalendarEvents();
|
const mockEvents = getMockCalendarEvents();
|
||||||
|
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
today.setHours(0, 0, 0, 0);
|
today.setHours(0, 0, 0, 0);
|
||||||
const weekFromNow = new Date();
|
const weekFromNow = new Date();
|
||||||
@@ -213,7 +208,7 @@ export function Calendar() {
|
|||||||
|
|
||||||
const createEvent = async () => {
|
const createEvent = async () => {
|
||||||
try {
|
try {
|
||||||
if (isDemoMode()) {
|
if (isDemoModeEnabled()) {
|
||||||
// Simulate event creation in demo mode
|
// Simulate event creation in demo mode
|
||||||
console.log('Creating event (demo mode):', newEvent());
|
console.log('Creating event (demo mode):', newEvent());
|
||||||
setShowEventModal(false);
|
setShowEventModal(false);
|
||||||
@@ -277,7 +272,7 @@ export function Calendar() {
|
|||||||
|
|
||||||
const toggleEventCompletion = async (eventId: number) => {
|
const toggleEventCompletion = async (eventId: number) => {
|
||||||
try {
|
try {
|
||||||
if (isDemoMode()) {
|
if (isDemoModeEnabled()) {
|
||||||
// Simulate event completion toggle in demo mode
|
// Simulate event completion toggle in demo mode
|
||||||
console.log('Toggling event completion (demo mode):', eventId);
|
console.log('Toggling event completion (demo mode):', eventId);
|
||||||
fetchCalendarData();
|
fetchCalendarData();
|
||||||
@@ -358,6 +353,18 @@ export function Calendar() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
|
{/* Demo Mode Indicator */}
|
||||||
|
<Show when={isDemoModeEnabled()}>
|
||||||
|
<div class="bg-yellow-100 dark:bg-yellow-900/20 border border-yellow-300 dark:border-yellow-800 rounded-lg p-3 mb-4">
|
||||||
|
<p class="text-yellow-800 dark:text-yellow-200 text-sm font-medium">
|
||||||
|
Demo Mode Active - Showing sample calendar data
|
||||||
|
</p>
|
||||||
|
<p class="text-yellow-700 dark:text-yellow-300 text-xs mt-1">
|
||||||
|
Today: {todayEvents().length} events | Upcoming: {upcomingEvents().length} events | Deadlines: {deadlines().length}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
{/* Header with Current Time */}
|
{/* Header with Current Time */}
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -440,7 +447,7 @@ export function Calendar() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Enhanced Calendar Grid with Events */}
|
{/* Enhanced Calendar Grid with Events */}
|
||||||
<div class="grid grid-cols-7 gap-1 text-sm auto-rows-fr">
|
<div class="grid grid-cols-7 gap-1 text-sm">
|
||||||
{['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map(day => (
|
{['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map(day => (
|
||||||
<div class="text-center text-sm font-medium text-muted-foreground p-2">
|
<div class="text-center text-sm font-medium text-muted-foreground p-2">
|
||||||
{day}
|
{day}
|
||||||
@@ -461,18 +468,16 @@ export function Calendar() {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={() => openEventModal(date)}
|
onClick={() => openEventModal(date)}
|
||||||
class={`border border-border rounded-lg p-1 cursor-pointer hover:bg-accent transition-colors relative overflow-hidden ${
|
class={`border border-border rounded-lg p-1 cursor-pointer hover:bg-accent transition-colors relative overflow-hidden h-24 flex flex-col ${
|
||||||
isToday ? 'bg-primary/10 border-primary' : ''
|
isToday ? 'bg-primary/10 border-primary' : ''
|
||||||
} ${!isCurrentMonth ? 'opacity-40' : ''} ${
|
} ${!isCurrentMonth ? 'opacity-40' : ''}`}
|
||||||
dayEvents.length > 3 ? 'row-span-2 min-h-[5rem]' : 'min-h-[3.5rem]'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<div class="text-sm font-medium">{date.getDate()}</div>
|
<div class="text-sm font-medium shrink-0">{date.getDate()}</div>
|
||||||
{/* Event indicators */}
|
{/* Event indicators */}
|
||||||
<div class="space-y-1 mt-1">
|
<div class="flex-1 overflow-hidden flex flex-col justify-start space-y-0.5 mt-1">
|
||||||
{dayEvents.slice(0, 3).map((event: CalendarEvent) => (
|
{dayEvents.slice(0, 3).map((event: CalendarEvent) => (
|
||||||
<div
|
<div
|
||||||
class={`text-xs px-1 py-0.5 rounded truncate w-full cursor-pointer hover:opacity-80 transition-opacity ${
|
class={`text-xs px-1 py-0.5 rounded truncate w-full cursor-pointer hover:opacity-80 transition-opacity leading-none ${
|
||||||
event.type === 'deadline'
|
event.type === 'deadline'
|
||||||
? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400 border border-red-200 dark:border-red-800'
|
? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400 border border-red-200 dark:border-red-800'
|
||||||
: event.type === 'meeting'
|
: event.type === 'meeting'
|
||||||
@@ -481,19 +486,19 @@ export function Calendar() {
|
|||||||
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
|
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
|
||||||
: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
|
: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
|
||||||
}`}
|
}`}
|
||||||
style={`font-size: 10px;`}
|
style={`font-size: 9px; line-height: 1.2;`}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setSelectedTask(event);
|
setSelectedTask(event);
|
||||||
setShowTaskDetailModal(true);
|
setShowTaskDetailModal(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{event.title.length > 12 ? event.title.substring(0, 12) + '...' : event.title}
|
{event.title.length > 10 ? event.title.substring(0, 10) + '...' : event.title}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{dayEvents.length > 3 && (
|
{dayEvents.length > 3 && (
|
||||||
<div
|
<div
|
||||||
class="text-xs text-muted-foreground font-medium cursor-pointer hover:text-primary transition-colors underline"
|
class="text-xs text-muted-foreground font-medium cursor-pointer hover:text-primary transition-colors underline leading-none mt-0.5"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
// Show all events for this day
|
// Show all events for this day
|
||||||
@@ -879,33 +884,33 @@ export function Calendar() {
|
|||||||
<label class="block text-sm font-medium mb-1">
|
<label class="block text-sm font-medium mb-1">
|
||||||
{newEvent().is_all_day ? 'Event Date' : 'Start Time'}
|
{newEvent().is_all_day ? 'Event Date' : 'Start Time'}
|
||||||
</label>
|
</label>
|
||||||
<DateTimePicker
|
<DateRangePicker
|
||||||
value={newEvent().start_time ? new Date(newEvent().start_time) : undefined}
|
value={newEvent().start_time ? { start: new Date(newEvent().start_time), end: new Date(newEvent().end_time || newEvent().start_time) } : undefined}
|
||||||
onChange={(date) => {
|
onChange={(range) => {
|
||||||
if (date) {
|
if (range && range.start) {
|
||||||
if (newEvent().is_all_day) {
|
if (newEvent().is_all_day) {
|
||||||
// For all-day events, set time to beginning of day
|
// For all-day events, set time to beginning of day
|
||||||
const startOfDay = new Date(date);
|
const startOfDay = new Date(range.start);
|
||||||
startOfDay.setHours(0, 0, 0, 0);
|
startOfDay.setHours(0, 0, 0, 0);
|
||||||
setNewEvent({ ...newEvent(), start_time: startOfDay.toISOString() });
|
setNewEvent({ ...newEvent(), start_time: startOfDay.toISOString() });
|
||||||
} else {
|
} else {
|
||||||
setNewEvent({ ...newEvent(), start_time: date.toISOString() });
|
setNewEvent({ ...newEvent(), start_time: range.start.toISOString() });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder={newEvent().is_all_day ? "Select event date" : "Select start time"}
|
placeholder={newEvent().is_all_day ? "Select event date" : "Select start time"}
|
||||||
class="w-full"
|
class="w-full"
|
||||||
dateOnly={newEvent().is_all_day}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!newEvent().is_all_day && (
|
{!newEvent().is_all_day && (
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium mb-1">End Time</label>
|
<label class="block text-sm font-medium mb-1">End Time</label>
|
||||||
<DateTimePicker
|
<DateRangePicker
|
||||||
value={newEvent().end_time ? new Date(newEvent().end_time) : undefined}
|
value={newEvent().start_time ? { start: new Date(newEvent().start_time), end: new Date(newEvent().end_time || newEvent().start_time) } : undefined}
|
||||||
onChange={(date) => {
|
onChange={(range) => {
|
||||||
if (date) {
|
if (range && range.start) {
|
||||||
setNewEvent({ ...newEvent(), end_time: date.toISOString() });
|
setNewEvent({ ...newEvent(), end_time: range.end ? range.end.toISOString() : range.start.toISOString() });
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder="Select end time"
|
placeholder="Select end time"
|
||||||
@@ -913,9 +918,8 @@ export function Calendar() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex gap-3 pt-4">
|
<div class="flex gap-2 mt-4">
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowEventModal(false)}
|
onClick={() => setShowEventModal(false)}
|
||||||
class="flex-1 px-4 py-2 border border-border rounded-lg hover:bg-accent transition-colors"
|
class="flex-1 px-4 py-2 border border-border rounded-lg hover:bg-accent transition-colors"
|
||||||
@@ -933,6 +937,7 @@ export function Calendar() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
{/* Task Detail Modal */}
|
{/* Task Detail Modal */}
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ import {
|
|||||||
FileText as FileTextIcon,
|
FileText as FileTextIcon,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Settings
|
Settings,
|
||||||
|
Trash,
|
||||||
|
User
|
||||||
} from 'lucide-solid'
|
} from 'lucide-solid'
|
||||||
|
|
||||||
interface ChatMessage {
|
interface ChatMessage {
|
||||||
@@ -602,7 +604,7 @@ const Chat = () => {
|
|||||||
}}
|
}}
|
||||||
class="opacity-0 group-hover:opacity-100 transition-opacity"
|
class="opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
>
|
>
|
||||||
<span class="h-4 w-4">🗑️</span>
|
<Trash class="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -706,7 +708,7 @@ const Chat = () => {
|
|||||||
message.role === 'user' ? 'bg-primary-foreground/20' : 'bg-primary/10'
|
message.role === 'user' ? 'bg-primary-foreground/20' : 'bg-primary/10'
|
||||||
}`}>
|
}`}>
|
||||||
{message.role === 'user' ? (
|
{message.role === 'user' ? (
|
||||||
<span class="w-4 h-4 text-xs">👤</span>
|
<User class="w-4 h-4 text-xs" />
|
||||||
) : (
|
) : (
|
||||||
<AIProviderIcon
|
<AIProviderIcon
|
||||||
providerId={selectedModel()}
|
providerId={selectedModel()}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createSignal, onMount, Show } from 'solid-js';
|
import { createSignal, onMount, Show } from 'solid-js';
|
||||||
import { IconPalette, IconCheck, IconRepeat, IconSun, IconMoon, IconDownload, IconUpload, IconEye, IconEyeOff } from '@tabler/icons-solidjs';
|
import { IconPalette, IconCheck, IconRepeat, IconSun, IconMoon, IconDownload, IconUpload, IconEye, IconEyeOff } from '@tabler/icons-solidjs';
|
||||||
|
import { ColorPicker } from '@/components/ui/ColorPicker';
|
||||||
|
|
||||||
interface ColorScheme {
|
interface ColorScheme {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -445,20 +446,11 @@ export const ColorSwitcher = () => {
|
|||||||
<label class="block text-sm font-medium text-muted-foreground mb-2">
|
<label class="block text-sm font-medium text-muted-foreground mb-2">
|
||||||
Primary Color
|
Primary Color
|
||||||
</label>
|
</label>
|
||||||
<div class="flex gap-2">
|
<ColorPicker
|
||||||
<input
|
|
||||||
type="color"
|
|
||||||
value={customColors().primary}
|
value={customColors().primary}
|
||||||
onInput={(e) => setCustomColors(prev => ({ ...prev, primary: e.currentTarget.value }))}
|
onChange={(color) => setCustomColors(prev => ({ ...prev, primary: color }))}
|
||||||
class="h-10 w-16 rounded border border-input"
|
savedColors={['#5ab9ff', '#ff6b6b', '#4ecdc4', '#45b7d1', '#f9ca24', '#f0932b', '#eb4d4b', '#6ab04c']}
|
||||||
/>
|
/>
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={customColors().primary}
|
|
||||||
onInput={(e) => setCustomColors(prev => ({ ...prev, primary: e.currentTarget.value }))}
|
|
||||||
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -571,28 +571,38 @@ export const Dashboard = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="relative flex items-end justify-between h-full gap-1 md:gap-2">
|
<div class="relative flex items-end justify-between h-full gap-1 md:gap-2">
|
||||||
{['M', 'T', 'W', 'T', 'F', 'S', 'S'].map((day, index) => {
|
{['M', 'T', 'W', 'T', 'F', 'S', 'S'].map((day, index) => {
|
||||||
const weeklyActivity = stats().weeklyActivity || [12, 19, 8, 15, 22, 18, 25]; // Fallback data
|
const weeklyActivity = stats().weeklyActivity;
|
||||||
const activity = weeklyActivity[index];
|
const activity = weeklyActivity[index];
|
||||||
const maxActivity = Math.max(...weeklyActivity);
|
const maxActivity = Math.max(...weeklyActivity);
|
||||||
// Use dynamic scale based on actual data
|
const minActivity = Math.min(...weeklyActivity);
|
||||||
const fixedMax = Math.max(maxActivity, 30); // Ensure minimum scale for better visualization
|
|
||||||
const containerHeight = 128; // h-32 = 128px (base), md:h-36 = 144px
|
// Calculate responsive height with proper scaling
|
||||||
const availableHeight = containerHeight * 0.75; // Use 75% of container height to leave room for labels
|
let heightPercent;
|
||||||
const heightPercent = (activity / fixedMax) * (availableHeight / containerHeight) * 100;
|
if (maxActivity === minActivity) {
|
||||||
const minHeightPercent = (6 / containerHeight) * 100; // Minimum 6px height
|
// All values are the same, use 80% height for consistency
|
||||||
const finalHeightPercent = Math.max(heightPercent, minHeightPercent);
|
heightPercent = 80;
|
||||||
|
} else {
|
||||||
|
// Use the actual range for proportional scaling
|
||||||
|
const range = maxActivity - minActivity;
|
||||||
|
const normalizedValue = activity - minActivity;
|
||||||
|
// Scale to 20-90% range to ensure visibility while maintaining proportions
|
||||||
|
heightPercent = 20 + (normalizedValue / range) * 70;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure minimum height for very small values but maintain proportion
|
||||||
|
const finalHeightPercent = Math.max(heightPercent, 8);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="flex flex-col items-center flex-1 gap-2 group min-w-0 max-w-4">
|
<div class="flex flex-col items-center flex-1 gap-2 group min-w-0 max-w-4 h-full">
|
||||||
<div class="relative w-full max-w-2 md:max-w-3 flex flex-col items-center">
|
<div class="relative w-full max-w-2 md:max-w-3 flex flex-col items-center justify-end h-full">
|
||||||
<span
|
<span
|
||||||
class="text-xs font-medium text-primary mb-1 opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap absolute -top-5"
|
class="text-xs font-medium text-primary mb-1 opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap absolute -top-5 z-10"
|
||||||
>
|
>
|
||||||
{activity}
|
{activity}
|
||||||
</span>
|
</span>
|
||||||
<div
|
<div
|
||||||
class="w-full max-w-2 md:max-w-3 bg-primary rounded-t transition-all duration-500 hover:opacity-80 cursor-pointer hover:scale-105 weekly-bar"
|
class="w-full max-w-2 md:max-w-3 bg-primary rounded-t transition-all duration-500 hover:opacity-80 cursor-pointer hover:scale-105 weekly-bar"
|
||||||
style={`height: ${finalHeightPercent}%; background-color: hsl(199, 89%, 67%); min-height: 6px;`}
|
style={`height: ${finalHeightPercent}%; background-color: hsl(199, 89%, 67%); min-height: 4px;`}
|
||||||
title={`${day}: ${activity} activities`}
|
title={`${day}: ${activity} activities`}
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -605,8 +615,8 @@ export const Dashboard = () => {
|
|||||||
|
|
||||||
{/* Weekly summary */}
|
{/* Weekly summary */}
|
||||||
<div class="flex justify-between text-xs text-muted-foreground pt-2 border-t border-border">
|
<div class="flex justify-between text-xs text-muted-foreground pt-2 border-t border-border">
|
||||||
<span>Total: {(stats().weeklyActivity || [12, 19, 8, 15, 22, 18, 25]).reduce((a, b) => a + b, 0)} activities</span>
|
<span>Total: {stats().weeklyActivity.reduce((a, b) => a + b, 0)} activities</span>
|
||||||
<span>Avg: {Math.round((stats().weeklyActivity || [12, 19, 8, 15, 22, 18, 25]).reduce((a, b) => a + b, 0) / 7)} per day</span>
|
<span>Avg: {Math.round(stats().weeklyActivity.reduce((a, b) => a + b, 0) / 7)} per day</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { createSignal, onMount, For, Show } from 'solid-js';
|
|||||||
import { Card } from '@/components/ui/Card';
|
import { Card } from '@/components/ui/Card';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { SearchTagFilterBar } from '@/components/ui/SearchTagFilterBar';
|
import { SearchTagFilterBar } from '@/components/ui/SearchTagFilterBar';
|
||||||
import { FileUploadModal } from '@/components/ui/FileUploadModal';
|
import { FileUpload } from '@/components/ui/FileUpload';
|
||||||
import { FilePreviewModal } from '@/components/ui/FilePreviewModal';
|
import { FilePreviewModal } from '@/components/ui/FilePreviewModal';
|
||||||
import { getFileTypeConfig, formatFileSize, getFileCategoryColor } from '@/utils/fileTypes';
|
import { getFileTypeConfig, formatFileSize, getFileCategoryColor } from '@/utils/fileTypes';
|
||||||
import { getMockFiles } from '@/lib/mockData';
|
import { getMockFiles } from '@/lib/mockData';
|
||||||
@@ -218,28 +218,28 @@ export const Files = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const handleFileUpload = async (fileData: any) => {
|
const handleFileUpload = async (uploadedFiles: any[]) => {
|
||||||
try {
|
try {
|
||||||
// Mock upload - in real app, this would be an API call
|
// Convert uploaded files to FileItem format
|
||||||
const newFile: FileItem = {
|
const newFiles: FileItem[] = uploadedFiles.map((fileData) => ({
|
||||||
id: Date.now(),
|
id: Date.now() + Math.random(),
|
||||||
name: fileData.file?.name || fileData.linkUrl?.split('/').pop() || 'Untitled',
|
name: fileData.name || 'Untitled',
|
||||||
size: fileData.file?.size || 0,
|
size: fileData.size || 0,
|
||||||
type: fileData.file?.type || 'application/octet-stream',
|
type: fileData.type || 'application/octet-stream',
|
||||||
uploadedAt: new Date().toISOString(),
|
uploadedAt: new Date().toISOString(),
|
||||||
description: fileData.description,
|
description: '',
|
||||||
tags: fileData.tags,
|
tags: [],
|
||||||
associations: fileData.associations,
|
url: fileData.url,
|
||||||
url: fileData.linkUrl,
|
isLink: !!fileData.url,
|
||||||
isLink: fileData.isLinkMode,
|
downloadUrl: fileData.url || `/files/download/${Date.now()}`,
|
||||||
downloadUrl: fileData.isLinkMode ? fileData.linkUrl : `/files/download/${Date.now()}`,
|
viewUrl: fileData.url || `/files/view/${Date.now()}`,
|
||||||
viewUrl: fileData.isLinkMode ? fileData.linkUrl : `/files/view/${Date.now()}`,
|
|
||||||
shareUrl: `/files/share/${Date.now()}`
|
shareUrl: `/files/share/${Date.now()}`
|
||||||
};
|
}));
|
||||||
|
|
||||||
setFiles(prev => [newFile, ...prev]);
|
setFiles(prev => [...newFiles, ...prev]);
|
||||||
|
setShowUploadModal(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to upload file:', error);
|
console.error('Failed to upload files:', error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -493,10 +493,12 @@ export const Files = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* File Upload Modal */}
|
{/* File Upload Modal */}
|
||||||
<FileUploadModal
|
<FileUpload
|
||||||
isOpen={showUploadModal()}
|
isOpen={showUploadModal()}
|
||||||
onClose={() => setShowUploadModal(false)}
|
onClose={() => setShowUploadModal(false)}
|
||||||
onUpload={handleFileUpload}
|
onFilesChange={handleFileUpload}
|
||||||
|
maxFileSize={50}
|
||||||
|
acceptedTypes={['image/jpeg', 'image/png', 'application/pdf', 'video/mp4']}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* File Preview Modal */}
|
{/* File Preview Modal */}
|
||||||
|
|||||||
@@ -394,9 +394,9 @@ export const GitHub = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Two-way Grid: Contribution Graph and Languages - Responsive */}
|
{/* Two-way Grid: Contribution Graph and Languages - Responsive */}
|
||||||
<div class="grid grid-cols-1 xl:grid-cols-2 gap-6">
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
{/* Contribution Graph - Left Column (2/3 width on large screens) */}
|
{/* Contribution Graph - Left Column (larger) */}
|
||||||
<div class="xl:w-2/3">
|
<div class="lg:col-span-1">
|
||||||
<GitHubActivity
|
<GitHubActivity
|
||||||
title="Contribution Activity"
|
title="Contribution Activity"
|
||||||
showStats={false}
|
showStats={false}
|
||||||
@@ -409,8 +409,8 @@ export const GitHub = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Languages - Right Column (1/3 width on large screens) */}
|
{/* Languages - Right Column (smaller) */}
|
||||||
<Card class="p-6 xl:w-1/3">
|
<Card class="p-6 lg:col-span-1">
|
||||||
<h3 class="text-lg font-semibold text-foreground mb-4">Languages</h3>
|
<h3 class="text-lg font-semibold text-foreground mb-4">Languages</h3>
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
{githubStats().languages.map((language) => (
|
{githubStats().languages.map((language) => (
|
||||||
@@ -457,9 +457,9 @@ export const GitHub = () => {
|
|||||||
const finalHeightPercent = Math.max(heightPercent, minHeightPercent);
|
const finalHeightPercent = Math.max(heightPercent, minHeightPercent);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="flex flex-col items-center flex-1 gap-2 group min-w-0 max-w-8">
|
<div class="flex flex-col items-center flex-1 gap-2 group min-w-0 max-w-8 h-full">
|
||||||
<div class="relative w-full max-w-4 md:max-w-5 flex flex-col items-center">
|
<div class="relative w-full max-w-4 md:max-w-5 flex flex-col items-center justify-end h-full">
|
||||||
<span class="text-xs font-medium text-primary mb-1 opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap absolute -top-5">
|
<span class="text-xs font-medium text-primary mb-1 opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap absolute -top-5 z-10">
|
||||||
{activity}
|
{activity}
|
||||||
</span>
|
</span>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ export const LearningPaths = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fetch categories
|
// Fetch categories
|
||||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1';
|
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:9090/api/v1';
|
||||||
const categoriesResponse = await fetch(`${API_BASE_URL}/learning-paths/categories`, {
|
const categoriesResponse = await fetch(`${API_BASE_URL}/learning-paths/categories`, {
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||||
@@ -214,7 +214,7 @@ export const LearningPaths = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1';
|
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:9090/api/v1';
|
||||||
const response = await fetch(`${API_BASE_URL}/learning-paths/${pathId}/enroll`, {
|
const response = await fetch(`${API_BASE_URL}/learning-paths/${pathId}/enroll`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -364,16 +364,12 @@ export const LearningPaths = () => {
|
|||||||
Featured
|
Featured
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<img
|
<div class="absolute inset-0 flex items-center justify-center">
|
||||||
src={path.thumbnail}
|
<div class="w-16 h-16 bg-blue-500/20 rounded-full flex items-center justify-center">
|
||||||
alt={path.title}
|
<IconBook class="size-8 text-blue-400" />
|
||||||
class="w-full h-full object-cover filter grayscale"
|
</div>
|
||||||
onError={(e) => {
|
</div>
|
||||||
const target = e.currentTarget;
|
<div class="absolute bottom-0 left-0 right-0 h-20 bg-gradient-to-t from-[#262626] to-transparent"></div>
|
||||||
target.src = `https://placehold.co/600x400/1e293b/ffffff?text=${encodeURIComponent(path.category)}`;
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div class="absolute inset-0 bg-black/20 group-hover:bg-black/10 transition-colors"></div>
|
|
||||||
<div class="absolute bottom-4 left-4 right-4">
|
<div class="absolute bottom-4 left-4 right-4">
|
||||||
<div class="flex items-center gap-2 mb-2">
|
<div class="flex items-center gap-2 mb-2">
|
||||||
{getCategoryIcon(path.category)}
|
{getCategoryIcon(path.category)}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createSignal, onMount, For, Show } from 'solid-js';
|
import { createSignal, createEffect, onMount, For, Show } from 'solid-js';
|
||||||
import { Card } from '@/components/ui/Card';
|
import { Card } from '@/components/ui/Card';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { SearchTagFilterBar } from '@/components/ui/SearchTagFilterBar';
|
import { SearchTagFilterBar } from '@/components/ui/SearchTagFilterBar';
|
||||||
@@ -73,6 +73,8 @@ const renderMarkdownPreviewHtml = (content: string, maxBlocks = 4): string => {
|
|||||||
.replace(/\*(.*?)\*/g, '<em class="italic">$1<\/em>')
|
.replace(/\*(.*?)\*/g, '<em class="italic">$1<\/em>')
|
||||||
.replace(/`(.*?)`/g, '<code class="bg-[#262626] px-1 py-0.5 rounded text-xs">$1<\/code>')
|
.replace(/`(.*?)`/g, '<code class="bg-[#262626] px-1 py-0.5 rounded text-xs">$1<\/code>')
|
||||||
.replace(/```(.*?)\n([\s\S]*?)```/g, '<pre class="bg-[#262626] p-3 rounded mb-2 overflow-x-auto"><code class="text-xs">$2<\/code><\/pre>')
|
.replace(/```(.*?)\n([\s\S]*?)```/g, '<pre class="bg-[#262626] p-3 rounded mb-2 overflow-x-auto"><code class="text-xs">$2<\/code><\/pre>')
|
||||||
|
.replace(/^- \[ \] (.*$)/gim, '<div class="flex items-center gap-2 mb-1"><input type="checkbox" class="note-checkbox" style="width: 16px; height: 16px; cursor: pointer; accent-color: #3b82f6;" onclick="this.checked=!this.checked" onchange="this.parentElement.nextElementSibling.textContent=this.checked?\'x\':\' \'"><span class="text-xs">$1</span></div>')
|
||||||
|
.replace(/^- \[x\] (.*$)/gim, '<div class="flex items-center gap-2 mb-1"><input type="checkbox" checked class="note-checkbox" style="width: 16px; height: 16px; cursor: pointer; accent-color: #3b82f6;" onclick="this.checked=!this.checked" onchange="this.parentElement.nextElementSibling.textContent=this.checked?\'x\':\' \'"><span class="text-xs">$1</span></div>')
|
||||||
.replace(/^- (.*$)/gim, '<li class="ml-4 list-disc">$1<\/li>')
|
.replace(/^- (.*$)/gim, '<li class="ml-4 list-disc">$1<\/li>')
|
||||||
.replace(/^\d+\. (.*$)/gim, '<li class="ml-4 list-decimal">$1<\/li>')
|
.replace(/^\d+\. (.*$)/gim, '<li class="ml-4 list-decimal">$1<\/li>')
|
||||||
.replace(/> (.*$)/gim, '<blockquote class="border-l-4 border-[#444] pl-3 italic text-[#aaa] mb-2">$1<\/blockquote>')
|
.replace(/> (.*$)/gim, '<blockquote class="border-l-4 border-[#444] pl-3 italic text-[#aaa] mb-2">$1<\/blockquote>')
|
||||||
@@ -89,6 +91,8 @@ const renderPlainTextPreviewHtml = (content: string): string => {
|
|||||||
.replace(/(https?:\/\/[^\s]+)/g, '<a href="$1" class="text-blue-400 hover:underline" target="_blank" rel="noopener noreferrer">$1<\/a>')
|
.replace(/(https?:\/\/[^\s]+)/g, '<a href="$1" class="text-blue-400 hover:underline" target="_blank" rel="noopener noreferrer">$1<\/a>')
|
||||||
.replace(/\*\*(.*?)\*\*/g, '<strong class="font-semibold">$1<\/strong>')
|
.replace(/\*\*(.*?)\*\*/g, '<strong class="font-semibold">$1<\/strong>')
|
||||||
.replace(/\*(.*?)\*/g, '<em class="italic">$1<\/em>')
|
.replace(/\*(.*?)\*/g, '<em class="italic">$1<\/em>')
|
||||||
|
.replace(/^- \[ \] (.*$)/gim, '<div class="flex items-center gap-2 mb-1"><input type="checkbox" class="note-checkbox" style="width: 16px; height: 16px; cursor: pointer; accent-color: #3b82f6;" onclick="this.checked=!this.checked"><span class="text-xs">$1</span></div>')
|
||||||
|
.replace(/^- \[x\] (.*$)/gim, '<div class="flex items-center gap-2 mb-1"><input type="checkbox" checked class="note-checkbox" style="width: 16px; height: 16px; cursor: pointer; accent-color: #3b82f6;" onclick="this.checked=!this.checked"><span class="text-xs">$1</span></div>')
|
||||||
.split('\n')
|
.split('\n')
|
||||||
.slice(0, 6)
|
.slice(0, 6)
|
||||||
.map((line) => (line ? line : '<br \/>'))
|
.map((line) => (line ? line : '<br \/>'))
|
||||||
@@ -313,8 +317,86 @@ export const Notes = () => {
|
|||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Add this function to handle checkbox changes
|
||||||
|
const updateNoteCheckbox = (noteId: number, checkboxIndex: number, isChecked: boolean) => {
|
||||||
|
setNotes(prev => prev.map(note => {
|
||||||
|
if (note.id === noteId) {
|
||||||
|
const lines = note.content.split('\n');
|
||||||
|
let checkboxCount = 0;
|
||||||
|
|
||||||
|
const updatedLines = lines.map(line => {
|
||||||
|
const uncheckedMatch = line.match(/^- \[ \] (.*)$/);
|
||||||
|
const checkedMatch = line.match(/^- \[x\] (.*)$/);
|
||||||
|
|
||||||
|
if (uncheckedMatch || checkedMatch) {
|
||||||
|
if (checkboxCount === checkboxIndex) {
|
||||||
|
const text = uncheckedMatch ? uncheckedMatch[1] : (checkedMatch ? checkedMatch[1] : '');
|
||||||
|
return isChecked ? `- [x] ${text}` : `- [ ] ${text}`;
|
||||||
|
}
|
||||||
|
checkboxCount++;
|
||||||
|
}
|
||||||
|
return line;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...note,
|
||||||
|
content: updatedLines.join('\n'),
|
||||||
|
updatedAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return note;
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handler for updating note content from ViewNoteModal
|
||||||
|
const handleUpdateNoteContent = (noteId: number, content: string) => {
|
||||||
|
setNotes(prev => prev.map(note =>
|
||||||
|
note.id === noteId
|
||||||
|
? { ...note, content, updatedAt: new Date().toISOString() }
|
||||||
|
: note
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Make the function available globally for checkbox onchange handlers
|
||||||
|
createEffect(() => {
|
||||||
|
(window as any).updateNoteContent = (checkbox: HTMLInputElement) => {
|
||||||
|
const noteElement = checkbox.closest('[data-note-id]');
|
||||||
|
if (noteElement) {
|
||||||
|
const noteId = parseInt(noteElement.getAttribute('data-note-id') || '0');
|
||||||
|
const checkboxElements = noteElement.querySelectorAll('input[type="checkbox"]');
|
||||||
|
const checkboxIndex = Array.from(checkboxElements).indexOf(checkbox);
|
||||||
|
updateNoteCheckbox(noteId, checkboxIndex, checkbox.checked);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="p-6 space-y-6">
|
<div class="p-6 space-y-6">
|
||||||
|
<style>
|
||||||
|
{`
|
||||||
|
.note-checkbox {
|
||||||
|
width: 16px !important;
|
||||||
|
height: 16px !important;
|
||||||
|
cursor: pointer !important;
|
||||||
|
accent-color: #3b82f6 !important;
|
||||||
|
border: 2px solid #4b5563 !important;
|
||||||
|
border-radius: 3px !important;
|
||||||
|
transition: all 0.2s ease !important;
|
||||||
|
}
|
||||||
|
.note-checkbox:hover {
|
||||||
|
border-color: #3b82f6 !important;
|
||||||
|
transform: scale(1.1) !important;
|
||||||
|
}
|
||||||
|
.note-checkbox:checked {
|
||||||
|
background-color: #3b82f6 !important;
|
||||||
|
border-color: #3b82f6 !important;
|
||||||
|
}
|
||||||
|
.note-checkbox:focus {
|
||||||
|
outline: 2px solid #3b82f6 !important;
|
||||||
|
outline-offset: 2px !important;
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
</style>
|
||||||
<div class="flex justify-between items-center">
|
<div class="flex justify-between items-center">
|
||||||
<h1 class="text-3xl font-bold text-[#fafafa]">Notes</h1>
|
<h1 class="text-3xl font-bold text-[#fafafa]">Notes</h1>
|
||||||
<Button onClick={() => setShowAddModal(true)}>
|
<Button onClick={() => setShowAddModal(true)}>
|
||||||
@@ -356,6 +438,7 @@ export const Notes = () => {
|
|||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
{filteredNotes().map((note) => (
|
{filteredNotes().map((note) => (
|
||||||
<Card
|
<Card
|
||||||
|
data-note-id={note.id}
|
||||||
class={`p-6 cursor-pointer transition-all hover:shadow-lg hover:bg-[#1a1a1a] ${note.pinned ? 'border-l-4 border-l-primary' : ''}`}
|
class={`p-6 cursor-pointer transition-all hover:shadow-lg hover:bg-[#1a1a1a] ${note.pinned ? 'border-l-4 border-l-primary' : ''}`}
|
||||||
onClick={() => viewNote(note)}
|
onClick={() => viewNote(note)}
|
||||||
>
|
>
|
||||||
@@ -458,6 +541,8 @@ export const Notes = () => {
|
|||||||
.replace(/\*(.*?)\*/g, '<em class="italic">$1</em>')
|
.replace(/\*(.*?)\*/g, '<em class="italic">$1</em>')
|
||||||
.replace(/`(.*?)`/g, '<code class="bg-[#262626] px-1 py-0.5 rounded text-xs">$1</code>')
|
.replace(/`(.*?)`/g, '<code class="bg-[#262626] px-1 py-0.5 rounded text-xs">$1</code>')
|
||||||
.replace(/```(.*?)\n([\s\S]*?)```/g, '<pre class="bg-[#262626] p-3 rounded mb-2 overflow-x-auto"><code class="text-xs">$2</code></pre>')
|
.replace(/```(.*?)\n([\s\S]*?)```/g, '<pre class="bg-[#262626] p-3 rounded mb-2 overflow-x-auto"><code class="text-xs">$2</code></pre>')
|
||||||
|
.replace(/^- \[ \] (.*$)/gim, '<div class="flex items-center gap-2 mb-1"><input type="checkbox" class="note-checkbox" style="width: 16px; height: 16px; cursor: pointer; accent-color: #3b82f6;" onclick="this.checked=!this.checked" onchange="updateNoteContent(this)"><span class="text-sm">$1</span></div>')
|
||||||
|
.replace(/^- \[x\] (.*$)/gim, '<div class="flex items-center gap-2 mb-1"><input type="checkbox" checked class="note-checkbox" style="width: 16px; height: 16px; cursor: pointer; accent-color: #3b82f6;" onclick="this.checked=!this.checked" onchange="updateNoteContent(this)"><span class="text-sm">$1</span></div>')
|
||||||
.replace(/^- (.*$)/gim, '<li class="ml-4 list-disc">$1</li>')
|
.replace(/^- (.*$)/gim, '<li class="ml-4 list-disc">$1</li>')
|
||||||
.replace(/^\d+\. (.*$)/gim, '<li class="ml-4 list-decimal">$1</li>')
|
.replace(/^\d+\. (.*$)/gim, '<li class="ml-4 list-decimal">$1</li>')
|
||||||
.replace(/> (.*$)/gim, '<blockquote class="border-l-4 border-[#444] pl-3 italic text-[#aaa] mb-2">$1</blockquote>')
|
.replace(/> (.*$)/gim, '<blockquote class="border-l-4 border-[#444] pl-3 italic text-[#aaa] mb-2">$1</blockquote>')
|
||||||
@@ -466,6 +551,8 @@ export const Notes = () => {
|
|||||||
: note.content.replace(/(https?:\/\/[^\s]+)/g, '<a href="$1" class="text-blue-400 hover:underline" target="_blank" rel="noopener noreferrer">$1</a>')
|
: note.content.replace(/(https?:\/\/[^\s]+)/g, '<a href="$1" class="text-blue-400 hover:underline" target="_blank" rel="noopener noreferrer">$1</a>')
|
||||||
.replace(/\*\*(.*?)\*\*/g, '<strong class="font-semibold">$1</strong>')
|
.replace(/\*\*(.*?)\*\*/g, '<strong class="font-semibold">$1</strong>')
|
||||||
.replace(/\*(.*?)\*/g, '<em class="italic">$1</em>')
|
.replace(/\*(.*?)\*/g, '<em class="italic">$1</em>')
|
||||||
|
.replace(/^- \[ \] (.*$)/gim, '<div class="flex items-center gap-2 mb-1"><input type="checkbox" class="note-checkbox" style="width: 16px; height: 16px; cursor: pointer; accent-color: #3b82f6;" onclick="this.checked=!this.checked" onchange="updateNoteContent(this)"><span class="text-sm">$1</span></div>')
|
||||||
|
.replace(/^- \[x\] (.*$)/gim, '<div class="flex items-center gap-2 mb-1"><input type="checkbox" checked class="note-checkbox" style="width: 16px; height: 16px; cursor: pointer; accent-color: #3b82f6;" onclick="this.checked=!this.checked" onchange="updateNoteContent(this)"><span class="text-sm">$1</span></div>')
|
||||||
.split('\n').map((line) => line ? `<p class="mb-2">${line}</p>` : '<br />').join('')
|
.split('\n').map((line) => line ? `<p class="mb-2">${line}</p>` : '<br />').join('')
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -570,6 +657,7 @@ export const Notes = () => {
|
|||||||
onDelete={deleteNote}
|
onDelete={deleteNote}
|
||||||
onCopyContent={copyNoteContent}
|
onCopyContent={copyNoteContent}
|
||||||
onExportNote={exportNote}
|
onExportNote={exportNote}
|
||||||
|
onUpdateNote={handleUpdateNoteContent}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { IconUser, IconLock, IconTrash, IconKey, IconBrain, IconMail, IconSend,
|
|||||||
import { TwoFactorAuth } from '@/components/TwoFactorAuth';
|
import { TwoFactorAuth } from '@/components/TwoFactorAuth';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { AIProviderIcon } from '@/components/AIProviderIcon';
|
import { AIProviderIcon } from '@/components/AIProviderIcon';
|
||||||
|
import { ColorPicker } from '@/components/ui/ColorPicker';
|
||||||
|
|
||||||
export const Settings = () => {
|
export const Settings = () => {
|
||||||
const { authState, updateProfile, changePassword } = useAuth();
|
const { authState, updateProfile, changePassword } = useAuth();
|
||||||
@@ -14,12 +15,24 @@ export const Settings = () => {
|
|||||||
theme: 'dark',
|
theme: 'dark',
|
||||||
showBrowserSearch: true
|
showBrowserSearch: true
|
||||||
});
|
});
|
||||||
|
const [customColors, setCustomColors] = createSignal({
|
||||||
|
primary: '#5ab9ff',
|
||||||
|
background: '#000000',
|
||||||
|
foreground: '#ffffff',
|
||||||
|
muted: '#262727',
|
||||||
|
border: '#262626'
|
||||||
|
});
|
||||||
const [passwordData, setPasswordData] = createSignal({
|
const [passwordData, setPasswordData] = createSignal({
|
||||||
currentPassword: '',
|
currentPassword: '',
|
||||||
newPassword: '',
|
newPassword: '',
|
||||||
confirmPassword: ''
|
confirmPassword: ''
|
||||||
});
|
});
|
||||||
const [aiSettingsExpanded, setAISettingsExpanded] = createSignal(true);
|
const [aiSettingsExpanded, setAISettingsExpanded] = createSignal(true);
|
||||||
|
const [showMistralKey, setShowMistralKey] = createSignal(false);
|
||||||
|
const [showLongcatKey, setShowLongcatKey] = createSignal(false);
|
||||||
|
const [showGrokKey, setShowGrokKey] = createSignal(false);
|
||||||
|
const [showDeepseekKey, setShowDeepseekKey] = createSignal(false);
|
||||||
|
const [showOpenrouterKey, setShowOpenrouterKey] = createSignal(false);
|
||||||
const [aiSettings, setAISettings] = createSignal({
|
const [aiSettings, setAISettings] = createSignal({
|
||||||
mistral: { enabled: false, api_key: '', model: 'mistral-small-latest', model_thinking: 'mistral-large-latest' },
|
mistral: { enabled: false, api_key: '', model: 'mistral-small-latest', model_thinking: 'mistral-large-latest' },
|
||||||
grok: { enabled: false, api_key: '', base_url: 'https://api.x.ai/v1', model: 'grok-4-1-fast-non-reasoning-latest', model_thinking: 'grok-4-1-fast-reasoning-latest' },
|
grok: { enabled: false, api_key: '', base_url: 'https://api.x.ai/v1', model: 'grok-4-1-fast-non-reasoning-latest', model_thinking: 'grok-4-1-fast-reasoning-latest' },
|
||||||
@@ -350,6 +363,17 @@ export const Settings = () => {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-muted-foreground mb-2">
|
||||||
|
Primary Color
|
||||||
|
</label>
|
||||||
|
<ColorPicker
|
||||||
|
value={customColors().primary}
|
||||||
|
onChange={(color) => setCustomColors(prev => ({ ...prev, primary: color }))}
|
||||||
|
savedColors={['#5ab9ff', '#ff6b6b', '#4ecdc4', '#45b7d1', '#f9ca24', '#f0932b', '#eb4d4b', '#6ab04c']}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="flex items-center gap-2 text-sm font-medium text-muted-foreground mb-2">
|
<label class="flex items-center gap-2 text-sm font-medium text-muted-foreground mb-2">
|
||||||
<input
|
<input
|
||||||
@@ -485,14 +509,14 @@ export const Settings = () => {
|
|||||||
const totalAvailable = availableAIProviders().length || Object.keys(settings).length;
|
const totalAvailable = availableAIProviders().length || Object.keys(settings).length;
|
||||||
|
|
||||||
if (totalAvailable === 0) {
|
if (totalAvailable === 0) {
|
||||||
return '⚠️ No AI providers are available on the server. Check backend AI configuration.';
|
return 'No AI providers are available on the server. Check backend AI configuration.';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (enabledCount === 0) {
|
if (enabledCount === 0) {
|
||||||
return '⚠️ Providers are available but none are enabled. Enable at least one provider below.';
|
return 'Providers are available but none are enabled. Enable at least one provider below.';
|
||||||
}
|
}
|
||||||
|
|
||||||
return `✅ AI is ready. ${enabledCount} provider${enabledCount > 1 ? 's' : ''} enabled.`;
|
return `AI is ready. ${enabledCount} provider${enabledCount > 1 ? 's' : ''} enabled.`;
|
||||||
})()}
|
})()}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -647,8 +671,9 @@ export const Settings = () => {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-muted-foreground mb-1">API Key</label>
|
<label class="block text-sm font-medium text-muted-foreground mb-1">API Key</label>
|
||||||
|
<div class="relative">
|
||||||
<input
|
<input
|
||||||
type="password"
|
type={showMistralKey() ? "text" : "password"}
|
||||||
value={aiSettings().mistral.api_key}
|
value={aiSettings().mistral.api_key}
|
||||||
onInput={(e) => {
|
onInput={(e) => {
|
||||||
const settings = aiSettings();
|
const settings = aiSettings();
|
||||||
@@ -658,8 +683,16 @@ export const Settings = () => {
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
placeholder="Enter Mistral API key"
|
placeholder="Enter Mistral API key"
|
||||||
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring"
|
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 pr-10 text-sm text-foreground focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring"
|
||||||
/>
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowMistralKey(!showMistralKey())}
|
||||||
|
class="absolute right-2 top-1/2 transform -translate-y-1/2 text-muted-foreground hover:text-foreground focus:outline-none"
|
||||||
|
>
|
||||||
|
<IconKey class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-2 gap-3">
|
<div class="grid grid-cols-2 gap-3">
|
||||||
@@ -740,8 +773,9 @@ export const Settings = () => {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-muted-foreground mb-1">API Key</label>
|
<label class="block text-sm font-medium text-muted-foreground mb-1">API Key</label>
|
||||||
|
<div class="relative">
|
||||||
<input
|
<input
|
||||||
type="password"
|
type={showLongcatKey() ? "text" : "password"}
|
||||||
value={aiSettings().longcat.api_key}
|
value={aiSettings().longcat.api_key}
|
||||||
onInput={(e) => {
|
onInput={(e) => {
|
||||||
const settings = aiSettings();
|
const settings = aiSettings();
|
||||||
@@ -751,8 +785,16 @@ export const Settings = () => {
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
placeholder="Enter LongCat API key"
|
placeholder="Enter LongCat API key"
|
||||||
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring"
|
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 pr-10 text-sm text-foreground focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring"
|
||||||
/>
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowLongcatKey(!showLongcatKey())}
|
||||||
|
class="absolute right-2 top-1/2 transform -translate-y-1/2 text-muted-foreground hover:text-foreground focus:outline-none"
|
||||||
|
>
|
||||||
|
<IconKey class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
@@ -923,8 +965,9 @@ export const Settings = () => {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-muted-foreground mb-1">API Key</label>
|
<label class="block text-sm font-medium text-muted-foreground mb-1">API Key</label>
|
||||||
|
<div class="relative">
|
||||||
<input
|
<input
|
||||||
type="password"
|
type={showGrokKey() ? "text" : "password"}
|
||||||
value={aiSettings().grok.api_key}
|
value={aiSettings().grok.api_key}
|
||||||
onInput={(e) => {
|
onInput={(e) => {
|
||||||
const settings = aiSettings();
|
const settings = aiSettings();
|
||||||
@@ -934,8 +977,16 @@ export const Settings = () => {
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
placeholder="Enter Grok API key"
|
placeholder="Enter Grok API key"
|
||||||
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring"
|
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 pr-10 text-sm text-foreground focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring"
|
||||||
/>
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowGrokKey(!showGrokKey())}
|
||||||
|
class="absolute right-2 top-1/2 transform -translate-y-1/2 text-muted-foreground hover:text-foreground focus:outline-none"
|
||||||
|
>
|
||||||
|
<IconKey class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -1033,8 +1084,9 @@ export const Settings = () => {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-muted-foreground mb-1">API Key</label>
|
<label class="block text-sm font-medium text-muted-foreground mb-1">API Key</label>
|
||||||
|
<div class="relative">
|
||||||
<input
|
<input
|
||||||
type="password"
|
type={showDeepseekKey() ? "text" : "password"}
|
||||||
value={aiSettings().deepseek.api_key}
|
value={aiSettings().deepseek.api_key}
|
||||||
onInput={(e) => {
|
onInput={(e) => {
|
||||||
const settings = aiSettings();
|
const settings = aiSettings();
|
||||||
@@ -1044,8 +1096,16 @@ export const Settings = () => {
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
placeholder="Enter DeepSeek API key"
|
placeholder="Enter DeepSeek API key"
|
||||||
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring"
|
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 pr-10 text-sm text-foreground focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring"
|
||||||
/>
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowDeepseekKey(!showDeepseekKey())}
|
||||||
|
class="absolute right-2 top-1/2 transform -translate-y-1/2 text-muted-foreground hover:text-foreground focus:outline-none"
|
||||||
|
>
|
||||||
|
<IconKey class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -1236,8 +1296,9 @@ export const Settings = () => {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-muted-foreground mb-1">API Key</label>
|
<label class="block text-sm font-medium text-muted-foreground mb-1">API Key</label>
|
||||||
|
<div class="relative">
|
||||||
<input
|
<input
|
||||||
type="password"
|
type={showOpenrouterKey() ? "text" : "password"}
|
||||||
value={aiSettings().openrouter.api_key}
|
value={aiSettings().openrouter.api_key}
|
||||||
onInput={(e) => {
|
onInput={(e) => {
|
||||||
const settings = aiSettings();
|
const settings = aiSettings();
|
||||||
@@ -1247,8 +1308,16 @@ export const Settings = () => {
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
placeholder="Enter OpenRouter API key"
|
placeholder="Enter OpenRouter API key"
|
||||||
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring"
|
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 pr-10 text-sm text-foreground focus-visible:outline-none focus-visible:ring-1.5 focus-visible:ring-ring"
|
||||||
/>
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowOpenrouterKey(!showOpenrouterKey())}
|
||||||
|
class="absolute right-2 top-1/2 transform -translate-y-1/2 text-muted-foreground hover:text-foreground focus:outline-none"
|
||||||
|
>
|
||||||
|
<IconKey class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
import { ActivityFeed } from '@/components/ui/ActivityFeed';
|
import { ActivityFeed } from '@/components/ui/ActivityFeed';
|
||||||
import { getMockStats, getMockActivities } from '@/lib/mockData';
|
import { getMockStats, getMockActivities } from '@/lib/mockData';
|
||||||
import { formatDuration } from '@/lib/timeFormat';
|
import { formatDuration } from '@/lib/timeFormat';
|
||||||
|
import { isDemoMode } from '@/lib/demo-mode';
|
||||||
|
|
||||||
interface ActivityData {
|
interface ActivityData {
|
||||||
date: string;
|
date: string;
|
||||||
@@ -119,6 +120,12 @@ export const Stats = () => {
|
|||||||
return graph;
|
return graph;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Create test data with varied values to verify height calculations
|
||||||
|
const testWeeklyActivity = [8, 22, 15, 31, 18, 25, 12]; // Fixed test values
|
||||||
|
|
||||||
|
// Use demo mode data if available, otherwise use test data
|
||||||
|
const weeklyActivityData = isDemoMode() ? mockStats.weeklyActivity : testWeeklyActivity;
|
||||||
|
|
||||||
// Set stats using mock data
|
// Set stats using mock data
|
||||||
setStats({
|
setStats({
|
||||||
totalBookmarks: mockStats.totalBookmarks,
|
totalBookmarks: mockStats.totalBookmarks,
|
||||||
@@ -129,7 +136,7 @@ export const Stats = () => {
|
|||||||
activeTasks: mockStats.activeTasks,
|
activeTasks: mockStats.activeTasks,
|
||||||
storageUsed: mockStats.totalSize,
|
storageUsed: mockStats.totalSize,
|
||||||
storageTotal: '50 GB',
|
storageTotal: '50 GB',
|
||||||
weeklyActivity: [12, 19, 8, 15, 25, 6, 14], // Enhanced mock data for better visualization
|
weeklyActivity: weeklyActivityData, // Use demo mode or test data
|
||||||
monthlyGrowth: mockStats.monthlyGrowth,
|
monthlyGrowth: mockStats.monthlyGrowth,
|
||||||
topCategories: [
|
topCategories: [
|
||||||
{ name: 'Work', count: 45, color: 'hsl(var(--primary))' },
|
{ name: 'Work', count: 45, color: 'hsl(var(--primary))' },
|
||||||
@@ -157,11 +164,24 @@ export const Stats = () => {
|
|||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<div class="p-6 mt-4 pb-32 max-w-5xl mx-auto space-y-6">
|
<div class="p-6 mt-4 pb-32 max-w-5xl mx-auto space-y-6">
|
||||||
<div class="flex justify-between items-start">
|
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-bold">Statistics & Activity</h1>
|
<h1 class="text-2xl font-bold">Statistics & Activity</h1>
|
||||||
<p class="text-muted-foreground mt-2">Track your productivity, growth, and activity over time</p>
|
<p class="text-muted-foreground mt-2">Track your productivity, growth, and activity over time</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Demo Mode Indicator */}
|
||||||
|
<Show when={isDemoMode()}>
|
||||||
|
<div class="bg-yellow-100 dark:bg-yellow-900/20 border border-yellow-300 dark:border-yellow-800 rounded-lg p-3">
|
||||||
|
<p class="text-yellow-800 dark:text-yellow-200 text-sm font-medium">
|
||||||
|
Demo Mode Active - Showing sample data
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-between items-start">
|
||||||
|
<div></div>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -398,52 +418,52 @@ export const Stats = () => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Weekly Activity Chart */}
|
{/* Weekly Activity Chart */}
|
||||||
<div class="border rounded-lg p-6">
|
<div class="border rounded-lg p-4 sm:p-6">
|
||||||
<div class="flex items-center gap-2 mb-4">
|
<div class="flex items-center gap-2 mb-4">
|
||||||
<IconActivity class="size-5 text-primary" />
|
<IconActivity class="size-5 text-primary" />
|
||||||
<h3 class="text-lg font-semibold">Weekly Activity</h3>
|
<h3 class="text-lg font-semibold">Weekly Activity</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="relative h-32 md:h-36 px-6 weekly-activity-chart">
|
<div class="relative h-32 sm:h-36 md:h-40 lg:h-44 px-4 sm:px-6 weekly-activity-chart">
|
||||||
<div class="absolute inset-x-0 inset-y-2 pointer-events-none flex flex-col justify-between">
|
<div class="absolute inset-x-0 inset-y-2 pointer-events-none flex flex-col justify-between">
|
||||||
<div class="border-t border-border/60"></div>
|
<div class="border-t border-border/60"></div>
|
||||||
<div class="border-t border-border/40"></div>
|
<div class="border-t border-border/40"></div>
|
||||||
<div class="border-t border-border/30"></div>
|
<div class="border-t border-border/30"></div>
|
||||||
<div class="border-t border-border/20"></div>
|
<div class="border-t border-border/20"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="relative flex items-end justify-between h-full gap-3 md:gap-4">
|
<div class="relative flex items-end justify-between h-full gap-1 sm:gap-2">
|
||||||
{['M', 'T', 'W', 'T', 'F', 'S', 'S'].map((day, index) => {
|
{['M', 'T', 'W', 'T', 'F', 'S', 'S'].map((day, index) => {
|
||||||
const weeklyActivity = stats().weeklyActivity || [12, 19, 8, 15, 22, 18, 25]; // Fallback data
|
const weeklyActivity = stats().weeklyActivity;
|
||||||
const activity = weeklyActivity[index];
|
const activity = weeklyActivity[index];
|
||||||
const maxActivity = Math.max(...weeklyActivity);
|
const maxActivity = Math.max(...weeklyActivity);
|
||||||
// Use dynamic scale based on actual data
|
// Dynamic scale: use the highest value as the scale, with minimum of 25 for better visualization
|
||||||
const fixedMax = Math.max(maxActivity, 30); // Ensure minimum scale for better visualization
|
const scaleMax = Math.max(maxActivity, 25);
|
||||||
const containerHeight = 128; // h-32 = 128px (base), md:h-36 = 144px
|
// Calculate height percentage (use 85% of available height to leave room for labels)
|
||||||
const availableHeight = containerHeight * 0.75; // Use 75% of container height to leave room for labels
|
const heightPercent = (activity / scaleMax) * 85;
|
||||||
const heightPercent = (activity / fixedMax) * (availableHeight / containerHeight) * 100;
|
// Ensure minimum height for visibility
|
||||||
const minHeightPercent = (8 / containerHeight) * 100; // Minimum 8px height
|
const finalHeightPercent = Math.max(heightPercent, 5);
|
||||||
const finalHeightPercent = Math.max(heightPercent, minHeightPercent);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="flex flex-col items-center flex-1 gap-2 group min-w-0 max-w-8">
|
<div class="flex flex-col items-center flex-1 gap-2 group min-w-0 h-full">
|
||||||
<div class="relative w-full max-w-4 md:max-w-5 flex flex-col items-center">
|
<div class="relative w-full max-w-2 sm:max-w-3 md:max-w-4 flex flex-col items-center justify-end h-full">
|
||||||
<span class="text-xs font-medium text-primary mb-1 opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap absolute -top-5">
|
<span class="text-xs font-medium text-primary mb-1 opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap absolute -top-5 z-10 bg-background px-1 rounded shadow-sm">
|
||||||
{activity}
|
{activity}
|
||||||
</span>
|
</span>
|
||||||
<div
|
<div
|
||||||
class="w-full max-w-4 md:max-w-5 bg-primary rounded-t transition-all duration-500 hover:opacity-80 cursor-pointer hover:scale-105 weekly-bar"
|
class="w-full max-w-2 sm:max-w-3 md:max-w-4 bg-primary rounded-t transition-all duration-500 hover:opacity-80 cursor-pointer hover:scale-105 weekly-bar"
|
||||||
style={`height: ${finalHeightPercent}%; background-color: hsl(199, 89%, 67%); min-height: 8px;`}
|
style={`height: ${finalHeightPercent}%; min-height: 4px;`}
|
||||||
title={`${day}: ${activity} activities`}
|
title={`${day}: ${activity} activities (${finalHeightPercent.toFixed(1)}%)`}
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-xs text-muted-foreground font-medium mt-1">{day}</span>
|
<span class="text-xs text-muted-foreground font-medium mt-1 hidden sm:block">{day}</span>
|
||||||
|
<span class="text-xs text-muted-foreground font-medium mt-1 sm:hidden">{day.charAt(0)}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-between text-xs text-muted-foreground pt-2 border-t border-border">
|
<div class="flex flex-col sm:flex-row sm:justify-between text-xs text-muted-foreground pt-2 border-t border-border gap-1 sm:gap-0">
|
||||||
<span>Total: {stats().weeklyActivity.reduce((a, b) => a + b, 0)} activities</span>
|
<span>Total: {stats().weeklyActivity.reduce((a, b) => a + b, 0)} activities</span>
|
||||||
<span>Avg: {Math.round(stats().weeklyActivity.reduce((a, b) => a + b, 0) / 7)} per day</span>
|
<span>Avg: {Math.round(stats().weeklyActivity.reduce((a, b) => a + b, 0) / 7)} per day</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -144,6 +144,7 @@ export const Youtube = () => {
|
|||||||
const [editingChannel, setEditingChannel] = createSignal<FeaturedChannel | null>(null);
|
const [editingChannel, setEditingChannel] = createSignal<FeaturedChannel | null>(null);
|
||||||
const [successMessage, setSuccessMessage] = createSignal('');
|
const [successMessage, setSuccessMessage] = createSignal('');
|
||||||
const [channelFilter, setChannelFilter] = createSignal('');
|
const [channelFilter, setChannelFilter] = createSignal('');
|
||||||
|
const [predefinedVideosLoadTime, setPredefinedVideosLoadTime] = createSignal(0);
|
||||||
|
|
||||||
// Filter channels based on search query
|
// Filter channels based on search query
|
||||||
const filteredChannels = () => {
|
const filteredChannels = () => {
|
||||||
@@ -218,7 +219,15 @@ export const Youtube = () => {
|
|||||||
|
|
||||||
// Load predefined channel videos
|
// Load predefined channel videos
|
||||||
const loadPredefinedVideos = async () => {
|
const loadPredefinedVideos = async () => {
|
||||||
|
// Prevent duplicate calls if already loading or if called within last 2 seconds
|
||||||
|
const now = Date.now();
|
||||||
|
if (isLoadingPredefined() || (now - predefinedVideosLoadTime() < 2000)) {
|
||||||
|
console.log('Skipping loadPredefinedVideos - already loading or called too recently');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setIsLoadingPredefined(true);
|
setIsLoadingPredefined(true);
|
||||||
|
setPredefinedVideosLoadTime(now);
|
||||||
setPredefinedError('');
|
setPredefinedError('');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -605,7 +614,7 @@ export const Youtube = () => {
|
|||||||
class="flex items-center gap-2"
|
class="flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="w-4 h-4 text-white"
|
class="w-4 h-4"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
@@ -621,7 +630,7 @@ export const Youtube = () => {
|
|||||||
class="flex items-center gap-2"
|
class="flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="w-4 h-4 text-white"
|
class="w-4 h-4"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
@@ -637,7 +646,7 @@ export const Youtube = () => {
|
|||||||
class="flex items-center gap-2"
|
class="flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="w-4 h-4 text-white"
|
class="w-4 h-4"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
|
|||||||
@@ -25,24 +25,62 @@ export interface UpdateCheckResponse {
|
|||||||
|
|
||||||
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8080';
|
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8080';
|
||||||
|
|
||||||
|
// Check if we're in demo mode
|
||||||
|
const isDemoMode = () => {
|
||||||
|
return localStorage.getItem('demoMode') === 'true' ||
|
||||||
|
document.title.includes('Demo Mode') ||
|
||||||
|
window.location.search.includes('demo=true') ||
|
||||||
|
import.meta.env.VITE_DEMO_MODE === 'true';
|
||||||
|
};
|
||||||
|
|
||||||
export const updateService = {
|
export const updateService = {
|
||||||
// Check for available updates
|
// Check for available updates
|
||||||
async checkForUpdates(): Promise<UpdateCheckResponse> {
|
async checkForUpdates(): Promise<UpdateCheckResponse> {
|
||||||
|
// If in demo mode, return mock update data
|
||||||
|
if (isDemoMode()) {
|
||||||
|
console.log('[Demo Mode] Using mock update data');
|
||||||
|
return {
|
||||||
|
updateAvailable: true,
|
||||||
|
currentVersion: '1.0.0',
|
||||||
|
latestVersion: '1.0.1',
|
||||||
|
updateInfo: {
|
||||||
|
version: '1.0.1',
|
||||||
|
releaseNotes: '• New AI features added\n• Performance improvements\n• Bug fixes and security patches\n• Enhanced user interface',
|
||||||
|
downloadUrl: 'https://github.com/trackeep/trackeep/releases/latest',
|
||||||
|
mandatory: false,
|
||||||
|
size: '~25MB'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
console.log('[Real Mode] Checking for updates at:', `${API_BASE}/api/updates/check`);
|
||||||
const response = await fetch(`${API_BASE}/api/updates/check`, {
|
const response = await fetch(`${API_BASE}/api/updates/check`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`,
|
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`,
|
||||||
},
|
},
|
||||||
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return await response.json();
|
return await response.json();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
if (error instanceof Error && error.name === 'AbortError') {
|
||||||
|
throw new Error('Update check timed out');
|
||||||
|
}
|
||||||
|
|
||||||
console.error('Failed to check for updates:', error);
|
console.error('Failed to check for updates:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -50,6 +88,15 @@ export const updateService = {
|
|||||||
|
|
||||||
// Install an update
|
// Install an update
|
||||||
async installUpdate(version: string): Promise<{ message: string; version: string }> {
|
async installUpdate(version: string): Promise<{ message: string; version: string }> {
|
||||||
|
// If in demo mode, simulate update installation
|
||||||
|
if (isDemoMode()) {
|
||||||
|
console.log('[Demo Mode] Simulating update installation for version:', version);
|
||||||
|
return {
|
||||||
|
message: 'Update started',
|
||||||
|
version: version
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE}/api/updates/install`, {
|
const response = await fetch(`${API_BASE}/api/updates/install`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -73,6 +120,19 @@ export const updateService = {
|
|||||||
|
|
||||||
// Get update progress
|
// Get update progress
|
||||||
async getUpdateProgress(): Promise<UpdateStatus> {
|
async getUpdateProgress(): Promise<UpdateStatus> {
|
||||||
|
// If in demo mode, return mock progress
|
||||||
|
if (isDemoMode()) {
|
||||||
|
console.log('[Demo Mode] Using mock update progress');
|
||||||
|
return {
|
||||||
|
available: true,
|
||||||
|
downloading: false,
|
||||||
|
installing: false,
|
||||||
|
completed: false,
|
||||||
|
error: '',
|
||||||
|
progress: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE}/api/updates/progress`, {
|
const response = await fetch(`${API_BASE}/api/updates/progress`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
@@ -129,5 +189,52 @@ export const updateService = {
|
|||||||
isActive = false;
|
isActive = false;
|
||||||
clearInterval(intervalId);
|
clearInterval(intervalId);
|
||||||
};
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// Simulate update progress for demo mode
|
||||||
|
simulateUpdateProgress(callback: (progress: UpdateStatus) => void): () => void {
|
||||||
|
let isActive = true;
|
||||||
|
let progress = 0;
|
||||||
|
let phase = 'downloading'; // 'downloading' -> 'installing' -> 'completed'
|
||||||
|
|
||||||
|
const simulate = () => {
|
||||||
|
if (!isActive) return;
|
||||||
|
|
||||||
|
progress += Math.random() * 15 + 5; // Random progress increment
|
||||||
|
|
||||||
|
if (progress >= 100) {
|
||||||
|
progress = 100;
|
||||||
|
if (phase === 'downloading') {
|
||||||
|
phase = 'installing';
|
||||||
|
progress = 0;
|
||||||
|
} else if (phase === 'installing') {
|
||||||
|
phase = 'completed';
|
||||||
|
isActive = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateStatus: UpdateStatus = {
|
||||||
|
available: true,
|
||||||
|
downloading: phase === 'downloading',
|
||||||
|
installing: phase === 'installing',
|
||||||
|
completed: phase === 'completed',
|
||||||
|
error: '',
|
||||||
|
progress: progress
|
||||||
|
};
|
||||||
|
|
||||||
|
callback(updateStatus);
|
||||||
|
|
||||||
|
if (isActive) {
|
||||||
|
const delay = phase === 'downloading' ? 500 : 1000; // Faster download, slower install
|
||||||
|
setTimeout(simulate, delay);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Start simulation
|
||||||
|
simulate();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isActive = false;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import { createSignal } from 'solid-js';
|
||||||
|
import { updateService, type UpdateInfo, type UpdateStatus } from '../services/updateService';
|
||||||
|
|
||||||
|
// Global update state store
|
||||||
|
const [updateAvailable, setUpdateAvailable] = createSignal(false);
|
||||||
|
const [updateInfo, setUpdateInfo] = createSignal<UpdateInfo | null>(null);
|
||||||
|
const [updateStatus, setUpdateStatus] = createSignal<UpdateStatus>({
|
||||||
|
available: false,
|
||||||
|
downloading: false,
|
||||||
|
installing: false,
|
||||||
|
completed: false,
|
||||||
|
progress: 0
|
||||||
|
});
|
||||||
|
const [isChecking, setIsChecking] = createSignal(false);
|
||||||
|
const [error, setError] = createSignal<string | null>(null);
|
||||||
|
const [currentVersion, setCurrentVersion] = createSignal('1.0.0');
|
||||||
|
const [lastCheckTime, setLastCheckTime] = createSignal<number>(0);
|
||||||
|
|
||||||
|
let pollCleanup: (() => void) | null = null;
|
||||||
|
let checkInterval: number | null = null;
|
||||||
|
|
||||||
|
let checkInProgress = false;
|
||||||
|
|
||||||
|
// Check for updates
|
||||||
|
const checkForUpdates = async () => {
|
||||||
|
// Prevent multiple simultaneous checks with both signal and flag
|
||||||
|
if (isChecking() || checkInProgress) return;
|
||||||
|
|
||||||
|
checkInProgress = true;
|
||||||
|
setIsChecking(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await updateService.checkForUpdates();
|
||||||
|
setUpdateAvailable(response.updateAvailable);
|
||||||
|
setUpdateInfo(response.updateInfo || null);
|
||||||
|
setCurrentVersion(response.currentVersion);
|
||||||
|
setLastCheckTime(Date.now());
|
||||||
|
|
||||||
|
// Save last check time to localStorage
|
||||||
|
localStorage.setItem('lastUpdateCheck', Date.now().toString());
|
||||||
|
|
||||||
|
if (response.updateAvailable && response.updateInfo) {
|
||||||
|
setUpdateStatus(prev => ({ ...prev, available: true }));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to check for updates:', err);
|
||||||
|
setError('Failed to check for updates');
|
||||||
|
} finally {
|
||||||
|
setIsChecking(false);
|
||||||
|
checkInProgress = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Install update
|
||||||
|
const installUpdate = async () => {
|
||||||
|
if (!updateInfo()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setError(null);
|
||||||
|
await updateService.installUpdate(updateInfo()!.version);
|
||||||
|
|
||||||
|
// Start polling for progress or simulation in demo mode
|
||||||
|
const isDemoMode = localStorage.getItem('demoMode') === 'true' ||
|
||||||
|
document.title.includes('Demo Mode') ||
|
||||||
|
window.location.search.includes('demo=true') ||
|
||||||
|
import.meta.env.VITE_DEMO_MODE === 'true';
|
||||||
|
|
||||||
|
if (isDemoMode) {
|
||||||
|
pollCleanup = updateService.simulateUpdateProgress((progress: UpdateStatus) => {
|
||||||
|
setUpdateStatus(progress);
|
||||||
|
|
||||||
|
if (progress.completed) {
|
||||||
|
// Show success notification or trigger reload
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.reload();
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (progress.error) {
|
||||||
|
setError(progress.error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
pollCleanup = updateService.pollUpdateProgress((progress: UpdateStatus) => {
|
||||||
|
setUpdateStatus(progress);
|
||||||
|
|
||||||
|
if (progress.completed) {
|
||||||
|
// Show success notification or trigger reload
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.reload();
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (progress.error) {
|
||||||
|
setError(progress.error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to install update:', err);
|
||||||
|
setError('Failed to install update');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cancel update
|
||||||
|
const cancelUpdate = () => {
|
||||||
|
if (pollCleanup) {
|
||||||
|
pollCleanup();
|
||||||
|
pollCleanup = null;
|
||||||
|
}
|
||||||
|
setUpdateStatus({
|
||||||
|
available: updateAvailable(),
|
||||||
|
downloading: false,
|
||||||
|
installing: false,
|
||||||
|
completed: false,
|
||||||
|
progress: 0
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize update checking
|
||||||
|
const initializeUpdateChecking = () => {
|
||||||
|
// Set current version
|
||||||
|
setCurrentVersion(updateService.getCurrentVersion());
|
||||||
|
|
||||||
|
// Check if last check was more than 24 hours ago
|
||||||
|
const lastCheckTimeStr = localStorage.getItem('lastUpdateCheck');
|
||||||
|
const now = Date.now();
|
||||||
|
const twentyFourHours = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
if (!lastCheckTimeStr || (now - parseInt(lastCheckTimeStr)) > twentyFourHours) {
|
||||||
|
// Check for updates on initialization if it's been more than 24 hours
|
||||||
|
checkForUpdates();
|
||||||
|
} else {
|
||||||
|
setLastCheckTime(parseInt(lastCheckTimeStr));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up periodic checking (every 24 hours)
|
||||||
|
checkInterval = setInterval(checkForUpdates, twentyFourHours);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
const cleanup = () => {
|
||||||
|
if (checkInterval) {
|
||||||
|
clearInterval(checkInterval);
|
||||||
|
checkInterval = null;
|
||||||
|
}
|
||||||
|
if (pollCleanup) {
|
||||||
|
pollCleanup();
|
||||||
|
pollCleanup = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Auto-initialize when store is imported
|
||||||
|
let initialized = false;
|
||||||
|
const ensureInitialized = () => {
|
||||||
|
if (!initialized) {
|
||||||
|
initializeUpdateChecking();
|
||||||
|
initialized = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Export store functions and signals
|
||||||
|
export const updateStore = {
|
||||||
|
// Signals
|
||||||
|
updateAvailable,
|
||||||
|
updateInfo,
|
||||||
|
updateStatus,
|
||||||
|
isChecking,
|
||||||
|
error,
|
||||||
|
currentVersion,
|
||||||
|
lastCheckTime,
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
checkForUpdates,
|
||||||
|
installUpdate,
|
||||||
|
cancelUpdate,
|
||||||
|
|
||||||
|
// Lifecycle
|
||||||
|
ensureInitialized,
|
||||||
|
cleanup
|
||||||
|
};
|
||||||
|
|
||||||
|
// Auto-cleanup on page unload
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.addEventListener('beforeunload', cleanup);
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
@@ -0,0 +1,143 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>YouTube API Test</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: Arial, sans-serif; margin: 20px; }
|
||||||
|
.test-section { margin: 20px 0; padding: 15px; border: 1px solid #ccc; }
|
||||||
|
.result { background: #f5f5f5; padding: 10px; margin: 10px 0; }
|
||||||
|
.error { background: #ffe6e6; color: red; }
|
||||||
|
.success { background: #e6ffe6; color: green; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>YouTube API Test</h1>
|
||||||
|
|
||||||
|
<div class="test-section">
|
||||||
|
<h2>Test YouTube Search</h2>
|
||||||
|
<button onclick="testSearch()">Test Search API</button>
|
||||||
|
<div id="search-result" class="result"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="test-section">
|
||||||
|
<h2>Test Channel Videos</h2>
|
||||||
|
<button onclick="testChannelVideos()">Test Channel Videos API</button>
|
||||||
|
<div id="channel-result" class="result"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="test-section">
|
||||||
|
<h2>Test Predefined Channels</h2>
|
||||||
|
<button onclick="testPredefinedChannels()">Test Predefined Channels API</button>
|
||||||
|
<div id="predefined-result" class="result"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Demo mode detection
|
||||||
|
function isDemoMode() {
|
||||||
|
return localStorage.getItem('demoMode') === 'true' ||
|
||||||
|
document.title.includes('Demo Mode') ||
|
||||||
|
window.location.search.includes('demo=true');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get auth headers (same as frontend)
|
||||||
|
function getAuthHeaders() {
|
||||||
|
const isDemo = isDemoMode();
|
||||||
|
let token = null;
|
||||||
|
|
||||||
|
if (isDemo) {
|
||||||
|
token = 'demo-token-' + Date.now();
|
||||||
|
} else {
|
||||||
|
token = localStorage.getItem('token') || localStorage.getItem('trackeep_token');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(token && { 'Authorization': `Bearer ${token}` }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testSearch() {
|
||||||
|
const resultDiv = document.getElementById('search-result');
|
||||||
|
try {
|
||||||
|
const response = await fetch('http://localhost:8080/api/v1/youtube/search', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
body: JSON.stringify({ query: 'test', max_results: 3 })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
resultDiv.className = 'result success';
|
||||||
|
resultDiv.innerHTML = `
|
||||||
|
<h3>✅ Search API Success!</h3>
|
||||||
|
<p>Found ${data.videos.length} videos</p>
|
||||||
|
<pre>${JSON.stringify(data, null, 2)}</pre>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
resultDiv.className = 'result error';
|
||||||
|
resultDiv.innerHTML = `<h3>❌ Search API Failed</h3><p>${error.message}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testChannelVideos() {
|
||||||
|
const resultDiv = document.getElementById('channel-result');
|
||||||
|
try {
|
||||||
|
const response = await fetch('http://localhost:8080/api/v1/youtube/channel-videos', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
body: JSON.stringify({ channel_id: '@Fireship', max_results: 3 })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
resultDiv.className = 'result success';
|
||||||
|
resultDiv.innerHTML = `
|
||||||
|
<h3>✅ Channel Videos API Success!</h3>
|
||||||
|
<p>Found ${data.videos.length} videos</p>
|
||||||
|
<pre>${JSON.stringify(data, null, 2)}</pre>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
resultDiv.className = 'result error';
|
||||||
|
resultDiv.innerHTML = `<h3>❌ Channel Videos API Failed</h3><p>${error.message}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testPredefinedChannels() {
|
||||||
|
const resultDiv = document.getElementById('predefined-result');
|
||||||
|
try {
|
||||||
|
const response = await fetch('http://localhost:8080/api/v1/youtube/predefined-channels', {
|
||||||
|
method: 'GET',
|
||||||
|
headers: getAuthHeaders()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
resultDiv.className = 'result success';
|
||||||
|
resultDiv.innerHTML = `
|
||||||
|
<h3>✅ Predefined Channels API Success!</h3>
|
||||||
|
<p>Found ${data.videos.length} videos</p>
|
||||||
|
<pre>${JSON.stringify(data.videos.slice(0, 2), null, 2)}</pre>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
resultDiv.className = 'result error';
|
||||||
|
resultDiv.innerHTML = `<h3>❌ Predefined Channels API Failed</h3><p>${error.message}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set demo mode for testing
|
||||||
|
localStorage.setItem('demoMode', 'true');
|
||||||
|
document.title = 'Trackeep - Demo Mode';
|
||||||
|
|
||||||
|
console.log('Demo mode enabled for testing');
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user