Files
xSaherelmWorkspace/Documents/Resources/Tools/xKanban.html
T
2026-09-12 23:04:57 +03:30

4860 lines
148 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="fa" dir="rtl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>xKanban | ابزار مدیریت بردهای کانبان</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Vazirmatn:wght@300;400;500;600;700;800&display=swap');
/* ============ Theme Variables ============ */
:root {
--primary: #1a365d;
--primary-light: #2c5282;
--primary-dark: #0f2341;
--accent: #c9a961;
--accent-dark: #a8893f;
--accent-light: #e0c789;
--bg: #f5f7fa;
--board-bg: #e2e8f0;
--card-bg: #ffffff;
--column-bg: #edf2f7;
--panel-bg: #ffffff;
--text: #1a202c;
--text-muted: #4a5568;
--text-light: #718096;
--border: #cbd5e0;
--border-light: #e2e8f0;
--success: #38a169;
--danger: #e53e3e;
--warning: #dd6b20;
--info: #3182ce;
--shadow: 0 4px 6px rgba(0,0,0,0.08), 0 10px 20px rgba(26,54,93,0.1);
--shadow-sm: 0 2px 4px rgba(0,0,0,0.06);
--shadow-lg: 0 10px 30px rgba(0,0,0,0.15);
--radius: 10px;
--radius-sm: 6px;
--sidebar-width: 280px;
--header-height: 64px;
--tabs-height: 48px;
--footer-height: 36px;
}
[data-theme="dark"] {
--primary: #2c5282;
--primary-light: #3b6fb5;
--primary-dark: #1a365d;
--accent: #e0c789;
--accent-dark: #c9a961;
--accent-light: #f0d9a0;
--bg: #1a202c;
--board-bg: #2d3748;
--card-bg: #2d3748;
--column-bg: #374151;
--panel-bg: #1f2937;
--text: #e2e8f0;
--text-muted: #cbd5e0;
--text-light: #a0aec0;
--border: #4a5568;
--border-light: #374151;
--shadow: 0 4px 6px rgba(0,0,0,0.3), 0 10px 20px rgba(0,0,0,0.4);
--shadow-sm: 0 2px 4px rgba(0,0,0,0.2);
--shadow-lg: 0 10px 30px rgba(0,0,0,0.5);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Vazirmatn', Tahoma, sans-serif;
background: var(--bg);
color: var(--text);
height: 100vh;
overflow: hidden;
font-size: 14px;
display: flex;
flex-direction: column;
transition: background 0.3s, color 0.3s;
}
/* ============ Header ============ */
.app-header {
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-light) 100%);
color: white;
padding: 0 20px;
height: var(--header-height);
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 3px solid var(--accent);
box-shadow: var(--shadow);
z-index: 100;
flex-shrink: 0;
position: relative;
overflow: hidden;
}
.app-header::before {
content: '';
position: absolute;
top: -50%;
left: -10%;
width: 300px;
height: 300px;
background: radial-gradient(circle, rgba(201,169,97,0.15) 0%, transparent 70%);
border-radius: 50%;
pointer-events: none;
}
.app-brand {
display: flex;
align-items: center;
gap: 14px;
position: relative;
z-index: 1;
}
.app-logo {
width: 44px; height: 44px;
background: var(--accent);
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
font-weight: 800;
color: var(--primary-dark);
box-shadow: 0 4px 12px rgba(0,0,0,0.25);
flex-shrink: 0;
}
.app-brand-text {
display: flex;
flex-direction: column;
gap: 2px;
}
.app-title {
font-size: 16px;
font-weight: 800;
letter-spacing: -0.3px;
color: white;
}
.app-subtitle {
font-size: 11px;
opacity: 0.85;
color: rgba(255,255,255,0.9);
}
.header-actions {
display: flex;
gap: 8px;
align-items: center;
position: relative;
z-index: 1;
}
/* ============ Buttons ============ */
.btn {
background: linear-gradient(135deg, var(--primary-light), var(--primary));
color: white;
border: none;
padding: 8px 14px;
border-radius: var(--radius-sm);
cursor: pointer;
font-family: inherit;
font-size: 13px;
font-weight: 600;
display: inline-flex;
align-items: center;
gap: 6px;
transition: all 0.2s;
box-shadow: 0 2px 4px rgba(0,0,0,0.15);
white-space: nowrap;
}
.btn:hover { transform: translateY(-1px); box-shadow: 0 4px 8px rgba(0,0,0,0.2); filter: brightness(1.1); }
.btn:active { transform: translateY(0); }
.btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
.btn-accent {
background: linear-gradient(135deg, var(--accent), var(--accent-dark));
color: var(--primary-dark);
font-weight: 700;
}
.btn-success { background: linear-gradient(135deg, #48bb78, #38a169); color: white; }
.btn-danger { background: linear-gradient(135deg, #f56565, #e53e3e); color: white; }
.btn-warning { background: linear-gradient(135deg, #ed8936, #dd6b20); color: white; }
.btn-info { background: linear-gradient(135deg, #4299e1, #3182ce); color: white; }
.btn-ghost {
background: rgba(26,54,93,0.08);
border: 1px solid var(--border);
color: var(--primary);
font-weight: 600;
}
.btn-ghost:hover {
background: rgba(26,54,93,0.15);
border-color: var(--primary-light);
}
[data-theme="dark"] .btn-ghost {
background: rgba(255,255,255,0.1);
border: 1px solid rgba(255,255,255,0.25);
color: white;
}
[data-theme="dark"] .btn-ghost:hover {
background: rgba(255,255,255,0.2);
}
.btn-sm { padding: 5px 10px; font-size: 12px; }
.btn-xs { padding: 3px 7px; font-size: 11px; }
/* ============ Main Layout ============ */
.app-main {
flex: 1;
display: flex;
overflow: hidden;
position: relative;
flex-direction: row;
}
/* ============ Sidebar ============ */
.sidebar {
order: -1;
width: var(--sidebar-width);
background: var(--panel-bg);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
transition: width 0.3s ease;
box-shadow: var(--shadow);
overflow: hidden;
flex-shrink: 0;
z-index: 50;
}
.sidebar.collapsed {
width: 0;
overflow: hidden;
}
.sidebar-header {
background: linear-gradient(135deg, var(--primary), var(--primary-light));
color: white;
padding: 12px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 2px solid var(--accent);
min-height: 48px;
}
.sidebar-title {
font-size: 13px;
font-weight: 700;
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
overflow: hidden;
}
.sidebar.collapsed .sidebar-title { display: none; }
.sidebar-body {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.sidebar.collapsed .sidebar-body { display: none; }
.sidebar-group {
margin-bottom: 6px;
border: 1px solid var(--border-light);
border-radius: var(--radius-sm);
overflow: hidden;
background: var(--card-bg);
}
.sidebar-group-header {
background: linear-gradient(135deg, var(--primary), var(--primary-light));
color: white;
padding: 10px 12px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
font-size: 13px;
font-weight: 700;
user-select: none;
transition: all 0.2s;
}
.sidebar-group-header:hover { filter: brightness(1.1); }
.sidebar-group-header .group-icon {
display: flex;
align-items: center;
gap: 8px;
}
.sidebar-group-header .group-arrow {
transition: transform 0.3s;
font-size: 12px;
}
.sidebar-group.collapsed .group-arrow { transform: rotate(90deg); }
.sidebar-group.collapsed .sidebar-group-body { display: none; }
.sidebar-group-body {
padding: 6px;
display: flex;
flex-direction: column;
gap: 4px;
}
.sidebar-btn {
background: var(--card-bg);
border: 1px solid var(--border-light);
color: var(--text);
padding: 8px 10px;
border-radius: var(--radius-sm);
cursor: pointer;
font-family: inherit;
font-size: 12px;
font-weight: 500;
display: flex;
align-items: center;
gap: 8px;
transition: all 0.2s;
text-align: right;
width: 100%;
}
.sidebar-btn:hover {
background: linear-gradient(135deg, var(--primary), var(--primary-light));
color: white;
border-color: var(--primary);
transform: translateX(-2px);
}
.sidebar-btn .btn-icon-emoji {
font-size: 15px;
width: 20px;
text-align: center;
}
.sidebar-btn.accent {
background: linear-gradient(135deg, var(--accent), var(--accent-dark));
color: var(--primary-dark);
font-weight: 700;
border-color: var(--accent);
}
.sidebar-btn.accent:hover { filter: brightness(1.1); }
.sidebar-btn.danger:hover {
background: linear-gradient(135deg, #f56565, #e53e3e);
border-color: var(--danger);
color: white;
}
/* ============ Tabs Bar ============ */
.tabs-bar {
background: linear-gradient(135deg, var(--primary-dark) 0%, var(--primary) 100%);
border-bottom: 2px solid var(--accent);
display: flex;
align-items: center;
padding: 0 10px;
min-height: var(--tabs-height);
box-shadow: var(--shadow-sm);
flex-shrink: 0;
gap: 6px;
position: relative;
}
[data-theme="dark"] .tabs-bar {
background: linear-gradient(135deg, #0f1724 0%, #1a2a44 100%);
}
.tabs-controls {
display: flex;
gap: 4px;
align-items: center;
padding: 4px 8px;
border-left: 1px solid rgba(255,255,255,0.15);
margin-left: 6px;
}
.tab-control-btn {
background: rgba(255,255,255,0.1);
border: 1px solid rgba(255,255,255,0.15);
color: white;
width: 32px; height: 32px;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
padding: 0;
}
.tab-control-btn:hover:not(:disabled) {
background: var(--accent);
color: var(--primary-dark);
border-color: var(--accent);
transform: translateY(-1px);
}
.tab-control-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.tab-control-btn.active {
background: var(--accent);
color: var(--primary-dark);
}
.tabs-list {
display: flex;
gap: 3px;
flex: 1;
overflow-x: auto;
padding: 4px 0;
}
.tabs-list::-webkit-scrollbar { height: 4px; }
.tabs-list::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.2); border-radius: 2px; }
.tab-item {
padding: 7px 14px;
background: rgba(255,255,255,0.08);
border: 1px solid rgba(255,255,255,0.1);
cursor: pointer;
font-family: inherit;
font-size: 12px;
font-weight: 500;
color: rgba(255,255,255,0.85);
border-radius: 6px 6px 0 0;
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
transition: all 0.2s;
position: relative;
border-bottom: 2px solid transparent;
}
.tab-item:hover {
background: rgba(255,255,255,0.15);
color: white;
}
.tab-item.active {
background: var(--accent);
color: var(--primary-dark);
font-weight: 700;
border-bottom-color: var(--primary-dark);
}
.tab-close {
width: 18px; height: 18px;
border-radius: 50%;
background: transparent;
border: none;
cursor: pointer;
color: inherit;
font-size: 11px;
display: flex;
align-items: center;
justify-content: center;
opacity: 0.6;
transition: all 0.2s;
}
.tab-item:hover .tab-close { opacity: 1; }
.tab-close:hover { background: var(--danger); color: white; }
/* ============ Search/Filter Bar ============ */
.search-bar {
background: var(--panel-bg);
border-bottom: 1px solid var(--border);
padding: 8px 16px;
display: flex;
gap: 10px;
align-items: center;
flex-shrink: 0;
flex-wrap: wrap;
}
.search-input-wrapper {
flex: 1;
min-width: 200px;
position: relative;
}
.search-input {
width: 100%;
padding: 7px 12px 7px 32px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-family: inherit;
font-size: 13px;
background: var(--card-bg);
color: var(--text);
}
.search-input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(201,169,97,0.15);
}
.search-icon {
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
color: var(--text-muted);
font-size: 14px;
pointer-events: none;
}
.filter-select {
padding: 7px 10px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-family: inherit;
font-size: 12px;
background: var(--card-bg);
color: var(--text);
min-width: 140px;
}
.filter-select:focus {
outline: none;
border-color: var(--accent);
}
.search-stats {
font-size: 11px;
color: var(--text-muted);
padding: 4px 10px;
background: var(--border-light);
border-radius: 12px;
white-space: nowrap;
}
/* ============ Stats Panel ============ */
.stats-panel {
background: var(--panel-bg);
border-bottom: 1px solid var(--border);
padding: 10px 16px;
display: flex;
gap: 12px;
align-items: center;
flex-shrink: 0;
flex-wrap: wrap;
}
.stat-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 12px;
background: var(--border-light);
border-radius: 16px;
font-size: 12px;
font-weight: 500;
color: var(--text);
}
.stat-chip .stat-icon { font-size: 14px; }
.stat-chip .stat-value {
font-weight: 700;
color: var(--primary);
}
[data-theme="dark"] .stat-chip .stat-value {
color: var(--accent);
}
.stat-chip.warning { background: #fef5e7; color: var(--warning); }
.stat-chip.danger { background: #fed7d7; color: var(--danger); }
.stat-chip.success { background: #c6f6d5; color: var(--success); }
[data-theme="dark"] .stat-chip.warning { background: rgba(221,107,32,0.2); }
[data-theme="dark"] .stat-chip.danger { background: rgba(229,62,62,0.2); }
[data-theme="dark"] .stat-chip.success { background: rgba(56,161,105,0.2); }
/* ============ Board Area ============ */
.board-area {
flex: 1;
overflow: auto;
padding: 20px;
background: var(--board-bg);
transition: background 0.3s;
}
.board-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-muted);
text-align: center;
}
.board-empty-icon { font-size: 80px; opacity: 0.3; margin-bottom: 20px; }
.board-empty h2 { font-size: 20px; color: var(--primary); margin-bottom: 10px; }
[data-theme="dark"] .board-empty h2 { color: var(--accent); }
.board-empty p { margin-bottom: 20px; }
.board-wrapper {
display: flex;
gap: 15px;
align-items: flex-start;
min-height: calc(100vh - 280px);
padding-bottom: 20px;
}
/* ============ Columns ============ */
.column {
border-radius: var(--radius);
min-width: 300px;
max-width: 300px;
max-height: calc(100vh - 280px);
display: flex;
flex-direction: column;
box-shadow: var(--shadow-sm);
border: 1px solid var(--border);
transition: all 0.2s;
}
.column.filtered-out { opacity: 0.3; pointer-events: none; }
.column.drag-over {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(201,169,97,0.25);
}
.column-header {
padding: 12px 14px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid var(--border);
cursor: grab;
background: linear-gradient(to bottom, rgba(255,255,255,0.3), transparent);
border-radius: var(--radius) var(--radius) 0 0;
}
.column-header:active { cursor: grabbing; }
.column.dragging-col { opacity: 0.4; transform: rotate(1deg); }
.column-title {
font-weight: 700;
font-size: 14px;
display: flex;
align-items: center;
gap: 8px;
flex: 1;
overflow: hidden;
}
.column-count {
font-size: 11px;
padding: 2px 8px;
border-radius: 10px;
font-weight: 700;
background: rgba(0,0,0,0.15);
color: inherit;
}
.column-actions { display: flex; gap: 4px; }
.column-action-btn {
background: transparent;
border: none;
cursor: pointer;
color: inherit;
opacity: 0.7;
padding: 4px 6px;
border-radius: 4px;
font-size: 14px;
transition: all 0.2s;
}
.column-action-btn:hover {
background: rgba(0,0,0,0.1);
opacity: 1;
}
.column-body {
padding: 10px;
overflow-y: auto;
flex: 1;
min-height: 60px;
}
.column-body.drag-target { background: rgba(201,169,97,0.1); }
.column-footer { padding: 8px; border-top: 1px solid var(--border); }
.add-card-btn {
width: 100%;
background: transparent;
border: 1px dashed var(--border);
color: var(--text-muted);
padding: 8px;
border-radius: var(--radius-sm);
cursor: pointer;
font-family: inherit;
font-size: 13px;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
transition: all 0.2s;
}
.add-card-btn:hover {
background: var(--card-bg);
border-color: var(--accent);
color: var(--accent-dark);
}
.add-column-btn {
background: rgba(255,255,255,0.5);
border: 2px dashed var(--border);
border-radius: var(--radius);
min-width: 280px;
min-height: 100px;
cursor: pointer;
color: var(--text-muted);
font-family: inherit;
font-size: 14px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
transition: all 0.2s;
}
[data-theme="dark"] .add-column-btn { background: rgba(255,255,255,0.05); }
.add-column-btn:hover {
background: var(--card-bg);
border-color: var(--accent);
color: var(--accent-dark);
}
/* ============ Cards ============ */
.card {
background: var(--card-bg);
border-radius: 8px;
padding: 12px;
margin-bottom: 8px;
box-shadow: var(--shadow-sm);
cursor: grab;
border: 1px solid var(--border);
transition: all 0.15s;
position: relative;
}
.card:hover {
box-shadow: 0 4px 12px rgba(0,0,0,0.12);
transform: translateY(-1px);
}
.card:active { cursor: grabbing; }
.card.dragging { opacity: 0.4; transform: rotate(2deg); }
.card.filtered-out { opacity: 0.15; pointer-events: none; }
.card.keyboard-focused { outline: 3px solid var(--accent); outline-offset: 2px; }
.card-tags { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 8px; }
.card-tag {
font-size: 10px;
padding: 2px 8px;
border-radius: 10px;
font-weight: 600;
color: white;
}
.card-title {
font-weight: 700;
font-size: 13px;
margin-bottom: 6px;
line-height: 1.5;
}
.card-desc {
font-size: 12px;
margin-bottom: 8px;
line-height: 1.6;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
opacity: 0.85;
}
.card-meta {
display: flex;
flex-wrap: wrap;
gap: 6px;
font-size: 11px;
align-items: center;
opacity: 0.9;
}
.card-meta-item {
display: inline-flex;
align-items: center;
gap: 3px;
background: rgba(0,0,0,0.08);
padding: 2px 6px;
border-radius: 4px;
}
[data-theme="dark"] .card-meta-item {
background: rgba(255,255,255,0.15);
color: var(--text);
}
.card-meta-item.overdue { background: #fed7d7; color: var(--danger); font-weight: 600; opacity: 1; }
.card-meta-item.soon { background: #fef5e7; color: var(--warning); font-weight: 600; opacity: 1; }
[data-theme="dark"] .card-meta-item.overdue { background: rgba(229,62,62,0.3); color: #feb2b2; }
[data-theme="dark"] .card-meta-item.soon { background: rgba(221,107,32,0.3); color: #fbd38d; }
.card-assignees { display: flex; margin-right: auto; }
.assignee-avatar {
width: 22px; height: 22px;
border-radius: 50%;
background: linear-gradient(135deg, var(--primary), var(--primary-light));
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: 700;
margin-right: -6px;
border: 2px solid var(--card-bg);
}
.card-checklist-progress {
width: 100%;
height: 4px;
background: rgba(0,0,0,0.1);
border-radius: 2px;
overflow: hidden;
margin-top: 8px;
}
[data-theme="dark"] .card-checklist-progress { background: rgba(255,255,255,0.15); }
.card-checklist-bar {
height: 100%;
background: linear-gradient(90deg, var(--success), #48bb78);
transition: width 0.3s;
}
.card-dep-indicator {
position: absolute;
top: 6px;
left: 6px;
font-size: 10px;
background: var(--info);
color: white;
padding: 1px 6px;
border-radius: 8px;
font-weight: 600;
}
/* ============ Modals ============ */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(26,54,93,0.6);
backdrop-filter: blur(3px);
display: none;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 20px;
animation: fadeIn 0.2s;
}
.modal-overlay.active { display: flex; }
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
.modal {
background: var(--panel-bg);
color: var(--text);
border-radius: 12px;
width: 100%;
max-width: 600px;
max-height: 90vh;
overflow: hidden;
display: flex;
flex-direction: column;
box-shadow: var(--shadow-lg);
animation: slideUp 0.25s;
}
.modal-lg { max-width: 800px; }
@keyframes slideUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.modal-header {
background: linear-gradient(135deg, var(--primary), var(--primary-light));
color: white;
padding: 16px 20px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 3px solid var(--accent);
}
.modal-title {
font-size: 16px;
font-weight: 700;
display: flex;
align-items: center;
gap: 10px;
}
.modal-close {
background: rgba(255,255,255,0.15);
border: none;
color: white;
width: 30px; height: 30px;
border-radius: 50%;
cursor: pointer;
font-size: 16px;
transition: all 0.2s;
}
.modal-close:hover { background: var(--danger); }
.modal-body {
padding: 20px;
overflow-y: auto;
flex: 1;
}
.modal-footer {
padding: 14px 20px;
border-top: 1px solid var(--border);
display: flex;
justify-content: flex-start;
gap: 8px;
background: var(--border-light);
}
/* ============ Forms ============ */
.form-group { margin-bottom: 16px; }
.form-label {
display: block;
font-size: 13px;
font-weight: 600;
color: var(--primary);
margin-bottom: 6px;
}
[data-theme="dark"] .form-label { color: var(--accent-light); }
.form-label .required { color: var(--danger); }
.form-input, .form-textarea, .form-select {
width: 100%;
padding: 9px 12px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-family: inherit;
font-size: 13px;
background: var(--card-bg);
color: var(--text);
transition: all 0.2s;
}
.form-input:focus, .form-textarea:focus, .form-select:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(201,169,97,0.15);
}
.form-textarea { resize: vertical; min-height: 70px; line-height: 1.6; }
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.form-hint {
font-size: 11px;
color: var(--text-muted);
margin-top: 4px;
}
.form-error {
font-size: 11px;
color: var(--danger);
margin-top: 4px;
}
/* ============ Info Box ============ */
.info-box {
background: linear-gradient(to left, #ebf8ff, #e6f6ff);
border: 1px solid #90cdf4;
border-right: 4px solid var(--primary-light);
padding: 18px 22px;
border-radius: 8px;
margin: 20px 0;
font-size: 12px;
color: var(--text);
}
[data-theme="dark"] .info-box {
background: rgba(49, 130, 206, 0.15);
border-color: rgba(49, 130, 206, 0.3);
border-right-color: var(--info);
color: var(--text);
}
.info-box strong { color: var(--primary); }
[data-theme="dark"] .info-box strong { color: var(--accent-light); }
/* ============ Webhook Section ============ */
.webhook-section {
background: var(--border-light);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 14px;
margin-top: 12px;
}
.webhook-section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.webhook-section-title {
font-size: 13px;
font-weight: 700;
color: var(--primary);
display: flex;
align-items: center;
gap: 6px;
}
[data-theme="dark"] .webhook-section-title { color: var(--accent-light); }
.webhook-toggle {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: var(--text-muted);
}
.switch {
position: relative;
display: inline-block;
width: 36px;
height: 20px;
}
.switch input { opacity: 0; width: 0; height: 0; }
.slider {
position: absolute;
cursor: pointer;
inset: 0;
background: var(--border);
transition: 0.3s;
border-radius: 20px;
}
.slider::before {
position: absolute;
content: "";
height: 14px;
width: 14px;
right: 3px;
bottom: 3px;
background: white;
transition: 0.3s;
border-radius: 50%;
}
input:checked + .slider { background: var(--success); }
input:checked + .slider::before { transform: translateX(-16px); }
.webhook-url-row {
display: flex;
gap: 6px;
margin-top: 8px;
}
.webhook-url-row .form-input {
flex: 1;
font-family: monospace;
font-size: 12px;
direction: ltr;
text-align: left;
}
.webhook-status {
display: flex;
align-items: center;
gap: 8px;
margin-top: 10px;
padding: 8px 10px;
background: var(--card-bg);
border-radius: 4px;
font-size: 11px;
color: var(--text-muted);
}
.webhook-status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--text-muted);
flex-shrink: 0;
}
.webhook-status-dot.success { background: var(--success); }
.webhook-status-dot.error { background: var(--danger); }
.webhook-status-dot.pending { background: var(--warning); animation: pulse 1.5s infinite; }
/* ============ Webhook Indicator ============ */
.webhook-indicator {
position: fixed;
bottom: 20px;
right: 20px;
background: var(--panel-bg);
border: 1px solid var(--border);
border-radius: 20px;
padding: 6px 12px;
font-size: 11px;
display: flex;
align-items: center;
gap: 6px;
box-shadow: var(--shadow);
z-index: 1500;
opacity: 0;
transition: opacity 0.3s;
pointer-events: none;
}
.webhook-indicator.visible { opacity: 1; }
.webhook-indicator.success { border-color: var(--success); }
.webhook-indicator.error { border-color: var(--danger); }
/* ============ Checklist ============ */
.checklist-items {
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 8px;
background: var(--border-light);
max-height: 200px;
overflow-y: auto;
}
.checklist-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px;
border-radius: 4px;
transition: background 0.15s;
}
.checklist-item:hover { background: var(--card-bg); }
.checklist-item input[type="checkbox"] {
width: 16px; height: 16px;
accent-color: var(--success);
cursor: pointer;
}
.checklist-item.done .checklist-text {
text-decoration: line-through;
color: var(--text-muted);
}
.checklist-text { flex: 1; font-size: 13px; }
.checklist-remove {
background: transparent;
border: none;
color: var(--danger);
cursor: pointer;
opacity: 0;
transition: opacity 0.2s;
}
.checklist-item:hover .checklist-remove { opacity: 1; }
.add-checklist-row {
display: flex;
gap: 6px;
margin-top: 8px;
}
/* ============ Tags ============ */
.tags-container {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 8px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--border-light);
min-height: 50px;
}
.tag-chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
color: white;
cursor: pointer;
transition: all 0.15s;
border: 2px solid transparent;
}
.tag-chip.selected {
border-color: var(--primary);
transform: scale(1.05);
box-shadow: 0 0 0 2px var(--card-bg), 0 0 0 4px var(--primary);
}
.tag-chip:hover { opacity: 0.85; }
.tag-manage-list {
max-height: 300px;
overflow-y: auto;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
}
.tag-manage-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-bottom: 1px solid var(--border);
}
.tag-manage-item:last-child { border-bottom: none; }
.tag-color-preview {
width: 30px; height: 30px;
border-radius: 6px;
border: 1px solid rgba(0,0,0,0.1);
}
.tag-manage-name { flex: 1; font-weight: 600; }
/* ============ History ============ */
.history-list {
max-height: 300px;
overflow-y: auto;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--border-light);
}
.history-item {
padding: 10px 12px;
border-bottom: 1px solid var(--border);
font-size: 12px;
display: flex;
gap: 10px;
align-items: flex-start;
}
.history-item:last-child { border-bottom: none; }
.history-icon {
width: 24px; height: 24px;
border-radius: 50%;
background: var(--primary);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
flex-shrink: 0;
}
.history-content { flex: 1; }
.history-date { font-size: 10px; color: var(--text-muted); margin-top: 2px; }
/* ============ Assignees ============ */
.assignees-container {
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 8px;
background: var(--border-light);
}
.assignee-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
background: var(--card-bg);
border-radius: 4px;
margin-bottom: 4px;
}
.assignee-item:last-child { margin-bottom: 0; }
.assignee-avatar-lg {
width: 28px; height: 28px;
border-radius: 50%;
background: linear-gradient(135deg, var(--primary), var(--primary-light));
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
}
.assignee-name { flex: 1; font-size: 13px; }
/* ============ Dependencies ============ */
.dep-list {
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 8px;
background: var(--border-light);
max-height: 180px;
overflow-y: auto;
}
.dep-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
background: var(--card-bg);
border-radius: 4px;
margin-bottom: 4px;
font-size: 12px;
}
.dep-item:last-child { margin-bottom: 0; }
.dep-status {
width: 8px; height: 8px;
border-radius: 50%;
background: var(--text-muted);
}
.dep-title { flex: 1; font-weight: 500; }
/* ============ Color Picker ============ */
.color-picker-row {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.color-swatch {
width: 30px; height: 30px;
border-radius: 6px;
cursor: pointer;
border: 2px solid transparent;
transition: all 0.15s;
}
.color-swatch:hover { transform: scale(1.1); }
.color-swatch.selected {
border-color: var(--primary);
transform: scale(1.15);
box-shadow: 0 0 0 2px var(--card-bg), 0 0 0 4px var(--primary);
}
.color-picker-custom {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
}
.color-picker-custom input[type="color"] {
width: 40px;
height: 34px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
cursor: pointer;
background: transparent;
}
/* ============ Toast ============ */
.toast-container {
position: fixed;
bottom: 20px;
left: 20px;
z-index: 2000;
display: flex;
flex-direction: column;
gap: 8px;
}
.toast {
background: var(--primary);
color: white;
padding: 12px 18px;
border-radius: 8px;
box-shadow: var(--shadow-lg);
font-size: 13px;
display: flex;
align-items: center;
gap: 10px;
animation: slideInLeft 0.3s;
min-width: 250px;
}
.toast.success { background: var(--success); }
.toast.error { background: var(--danger); }
.toast.warning { background: var(--warning); }
@keyframes slideInLeft {
from { opacity: 0; transform: translateX(-30px); }
to { opacity: 1; transform: translateX(0); }
}
/* ============ Auto-save Indicator ============ */
.autosave-indicator {
position: fixed;
bottom: 50px;
left: 20px;
background: var(--panel-bg);
color: var(--text);
padding: 6px 12px;
border-radius: 20px;
font-size: 11px;
box-shadow: var(--shadow);
border: 1px solid var(--border);
display: flex;
align-items: center;
gap: 6px;
opacity: 0;
transition: opacity 0.3s;
z-index: 1500;
}
.autosave-indicator.visible { opacity: 1; }
.autosave-dot {
width: 8px; height: 8px;
border-radius: 50%;
background: var(--success);
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
/* ============ Scrollbar ============ */
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
/* ============ Empty States ============ */
.empty-state {
text-align: center;
padding: 30px 20px;
color: var(--text-muted);
font-size: 13px;
}
.empty-state-icon {
font-size: 40px;
opacity: 0.3;
margin-bottom: 10px;
}
/* ============ Footer ============ */
.app-footer {
background: linear-gradient(135deg, var(--primary), var(--primary-light));
color: white;
padding: 8px 20px;
font-size: 11px;
display: flex;
justify-content: space-between;
align-items: center;
border-top: 2px solid var(--accent);
flex-shrink: 0;
}
.footer-brand { color: var(--accent); font-weight: 700; }
/* ============ Keyboard Hints ============ */
.kbd {
display: inline-block;
padding: 2px 6px;
background: var(--border-light);
border: 1px solid var(--border);
border-radius: 4px;
font-family: monospace;
font-size: 11px;
color: var(--text);
box-shadow: 0 1px 0 var(--border);
}
/* ============ Responsive ============ */
@media (max-width: 768px) {
.sidebar { position: absolute; right: 0; top: 0; bottom: 0; z-index: 200; }
.sidebar.collapsed { width: 0; }
}
</style>
</head>
<body>
<!-- ========== Header ========== -->
<header class="app-header" role="banner">
<div class="app-brand">
<div class="app-logo" aria-hidden="true"></div>
<div class="app-brand-text">
<div class="app-title">🎯 ابزار مدیریت بردهای کانبان</div>
<div class="app-subtitle">مدیریت بردهای کانبان با قابلیت ذخیره و بازیابی</div>
</div>
</div>
<div class="header-actions">
<div style="font-size:11px;opacity:0.85;">طراح: <strong style="color:var(--accent);">هادی خزاعی اصل</strong></div>
</div>
</header>
<!-- ========== Tabs Bar ========== -->
<div class="tabs-bar" role="navigation" aria-label="مدیریت بردها">
<div class="tabs-controls">
<button class="tab-control-btn" onclick="toggleSidebar()" title="بستن/باز کردن نوار ابزار" id="sidebarToggleBtn" aria-label="تغییر وضعیت نوار ابزار">✕</button>
<button class="tab-control-btn" onclick="CommandHistory.undo()" title="بازگشت (Ctrl+Z)" id="undoBtn" aria-label="بازگشت" disabled>↶</button>
<button class="tab-control-btn" onclick="CommandHistory.redo()" title="از نو (Ctrl+Y)" id="redoBtn" aria-label="از نو" disabled>↷</button>
<button class="tab-control-btn" onclick="toggleTheme()" title="تغییر تم روشن/تاریک" id="themeToggleBtn" aria-label="تغییر تم">🌙</button>
<button class="tab-control-btn" onclick="openNewBoardModal()" title="افزودن برد جدید" style="font-size:16px;font-weight:700;" aria-label="افزودن برد جدید">➕</button>
</div>
<div class="tabs-list" id="tabsList" role="tablist"></div>
</div>
<!-- ========== Main Layout ========== -->
<div class="app-main">
<!-- ========== Sidebar ========== -->
<aside class="sidebar" id="sidebar" role="complementary" aria-label="نوار ابزار">
<div class="sidebar-header">
<div class="sidebar-title">🛠️ ابزارها</div>
</div>
<div class="sidebar-body">
<div class="sidebar-group" data-group="project">
<div class="sidebar-group-header" onclick="toggleGroup('project')" role="button" aria-expanded="true">
<div class="group-icon">📁 پروژه</div>
<span class="group-arrow">▼</span>
</div>
<div class="sidebar-group-body">
<button class="sidebar-btn" onclick="openProjectSettings()">
<span class="btn-icon-emoji">📋</span>
<span>تنظیمات پروژه</span>
</button>
<button class="sidebar-btn" onclick="saveProject()">
<span class="btn-icon-emoji">💾</span>
<span>ذخیره پروژه</span>
</button>
<button class="sidebar-btn" onclick="loadProject()">
<span class="btn-icon-emoji">📂</span>
<span>بارگذاری</span>
</button>
<button class="sidebar-btn" onclick="openExportModal()">
<span class="btn-icon-emoji">📤</span>
<span>خروجی (MD/CSV)</span>
</button>
</div>
</div>
<div class="sidebar-group" data-group="board">
<div class="sidebar-group-header" onclick="toggleGroup('board')" role="button" aria-expanded="true">
<div class="group-icon">📊 مدیریت برد</div>
<span class="group-arrow">▼</span>
</div>
<div class="sidebar-group-body">
<button class="sidebar-btn accent" onclick="openNewBoardModal()">
<span class="btn-icon-emoji">➕</span>
<span>ایجاد برد جدید</span>
</button>
<button class="sidebar-btn" onclick="editCurrentBoard()">
<span class="btn-icon-emoji">✏️</span>
<span>ویرایش برد فعلی</span>
</button>
<button class="sidebar-btn danger" onclick="deleteCurrentBoard()">
<span class="btn-icon-emoji">🗑️</span>
<span>حذف برد فعلی</span>
</button>
</div>
</div>
<div class="sidebar-group" data-group="column">
<div class="sidebar-group-header" onclick="toggleGroup('column')" role="button" aria-expanded="true">
<div class="group-icon">📑 ستون‌ها</div>
<span class="group-arrow">▼</span>
</div>
<div class="sidebar-group-body">
<button class="sidebar-btn" onclick="openNewColumnModal()">
<span class="btn-icon-emoji">➕</span>
<span>افزودن ستون جدید</span>
</button>
<div class="form-hint" style="padding:6px;font-size:11px;">
💡 برای تغییر ترتیب ستون‌ها، هدر آن‌ها را دراگ کنید
</div>
</div>
</div>
<div class="sidebar-group" data-group="tags">
<div class="sidebar-group-header" onclick="toggleGroup('tags')" role="button" aria-expanded="true">
<div class="group-icon">🏷️ برچسب‌ها</div>
<span class="group-arrow">▼</span>
</div>
<div class="sidebar-group-body">
<button class="sidebar-btn" onclick="openTagManager()">
<span class="btn-icon-emoji">🏷️</span>
<span>مدیریت تگ‌های عمومی</span>
</button>
</div>
</div>
<div class="sidebar-group" data-group="view">
<div class="sidebar-group-header" onclick="toggleGroup('view')" role="button" aria-expanded="true">
<div class="group-icon">🎨 نمایش</div>
<span class="group-arrow">▼</span>
</div>
<div class="sidebar-group-body">
<button class="sidebar-btn" onclick="toggleTheme()">
<span class="btn-icon-emoji" id="themeIcon">🌙</span>
<span id="themeText">حالت روشن</span>
</button>
<button class="sidebar-btn" onclick="toggleSidebar()">
<span class="btn-icon-emoji">✕</span>
<span>بستن نوار ابزار</span>
</button>
</div>
</div>
<div class="sidebar-group" data-group="help">
<div class="sidebar-group-header" onclick="toggleGroup('help')" role="button" aria-expanded="true">
<div class="group-icon">⌨️ میانبرها</div>
<span class="group-arrow">▼</span>
</div>
<div class="sidebar-group-body">
<div style="padding:8px;font-size:11px;color:var(--text-muted);line-height:2;">
<div><span class="kbd">Ctrl+S</span> ذخیره سریع</div>
<div><span class="kbd">Ctrl+Z</span> بازگشت</div>
<div><span class="kbd">Ctrl+Y</span> از نو</div>
<div><span class="kbd">Ctrl+F</span> جستجو</div>
<div><span class="kbd">Esc</span> بستن پنجره‌ها</div>
<div><span class="kbd">Tab</span> انتخاب کارت</div>
<div><span class="kbd">Alt+→/←</span> جابجایی کارت</div>
<div><span class="kbd">Enter</span> ویرایش کارت</div>
</div>
</div>
</div>
</div>
</aside>
<!-- Board Container -->
<div style="flex:1;display:flex;flex-direction:column;overflow:hidden;">
<!-- Search/Filter Bar -->
<div class="search-bar" id="searchBar" role="search" aria-label="جستجو و فیلتر">
<div class="search-input-wrapper">
<input type="text" class="search-input" id="searchInput" placeholder="جستجو در عنوان و توضیحات کارت‌ها..." aria-label="جستجو">
<span class="search-icon">🔍</span>
</div>
<select class="filter-select" id="filterTag" aria-label="فیلتر بر اساس تگ">
<option value="">🏷️ همه تگ‌ها</option>
</select>
<select class="filter-select" id="filterAssignee" aria-label="فیلتر بر اساس مسئول">
<option value="">👥 همه مسئولین</option>
</select>
<select class="filter-select" id="filterStatus" aria-label="فیلتر بر اساس وضعیت">
<option value="">📊 همه وضعیت‌ها</option>
<option value="overdue">⚠️ عقب‌افتاده</option>
<option value="soon">⏰ نزدیک به موعد</option>
<option value="normal">✅ عادی</option>
<option value="no-date">📅 بدون تاریخ</option>
</select>
<div class="search-stats" id="searchStats">۰ کارت</div>
</div>
<!-- Stats Panel -->
<div class="stats-panel" id="statsPanel" role="status" aria-label="آمار برد">
<div class="stat-chip">
<span class="stat-icon">📋</span>
<span>کل کارت‌ها:</span>
<span class="stat-value" id="statTotal">0</span>
</div>
<div class="stat-chip success">
<span class="stat-icon">✅</span>
<span>تکمیل‌شده:</span>
<span class="stat-value" id="statDone">0</span>
</div>
<div class="stat-chip warning">
<span class="stat-icon">⏰</span>
<span>نزدیک به موعد:</span>
<span class="stat-value" id="statSoon">0</span>
</div>
<div class="stat-chip danger">
<span class="stat-icon">⚠️</span>
<span>عقب‌افتاده:</span>
<span class="stat-value" id="statOverdue">0</span>
</div>
<div class="stat-chip">
<span class="stat-icon">📊</span>
<span>پیشرفت:</span>
<span class="stat-value" id="statProgress">0%</span>
</div>
</div>
<!-- Board Area -->
<div class="board-area" id="boardArea" role="main">
<div class="board-empty" id="boardEmpty">
<div class="board-empty-icon">📋</div>
<h2>هیچ بردی وجود ندارد</h2>
<p>برای شروع، یک برد جدید ایجاد کنید</p>
<button class="btn btn-accent" onclick="openNewBoardModal()">➕ ایجاد اولین برد</button>
</div>
<div class="board-wrapper" id="boardWrapper" style="display:none;"></div>
</div>
</div>
</div>
<!-- ========== Footer ========== -->
<footer class="app-footer" role="contentinfo">
<div>طراحی و توسعه: <span class="footer-brand">هادی خزاعی اصل</span> | معمار و توسعه‌دهنده ارشد نرم‌افزار</div>
<div>نسخه ۱.۰.۰ | <span id="currentDate"></span></div>
</footer>
<!-- ========== Auto-save Indicator ========== -->
<div class="autosave-indicator" id="autosaveIndicator" role="status" aria-live="polite">
<div class="autosave-dot"></div>
<span id="autosaveText">ذخیره خودکار فعال</span>
</div>
<!-- ========== Webhook Indicator ========== -->
<div class="webhook-indicator" id="webhookIndicator" role="status" aria-live="polite">
<div class="webhook-status-dot" id="webhookIndicatorDot"></div>
<span id="webhookIndicatorText">در حال ارسال...</span>
</div>
<!-- ========== Modal: New/Edit Board ========== -->
<div class="modal-overlay" id="boardModal" role="dialog" aria-modal="true" aria-labelledby="boardModalTitle">
<div class="modal">
<div class="modal-header">
<div class="modal-title">📋 <span id="boardModalTitle">ایجاد برد جدید</span></div>
<button class="modal-close" onclick="closeModal('boardModal')" aria-label="بستن">✕</button>
</div>
<div class="modal-body">
<div class="form-group">
<label class="form-label">عنوان برد <span class="required">*</span></label>
<input type="text" class="form-input" id="boardTitle" placeholder="مثلاً: اسپرینت فعلی">
<div class="form-error" id="boardTitleError" style="display:none;"></div>
</div>
<div class="form-group">
<label class="form-label">توضیحات</label>
<textarea class="form-textarea" id="boardDesc" placeholder="توضیحات مربوط به برد..."></textarea>
</div>
<div class="form-group">
<label class="form-label">🎨 رنگ پس‌زمینه برد</label>
<div class="color-picker-row" id="boardBgColors"></div>
<div class="color-picker-custom">
<input type="color" id="boardBgCustom" value="#e2e8f0" onchange="selectCustomColor('board', 'bg', this.value)">
<span style="font-size:12px;color:var(--text-muted);">انتخاب رنگ دلخواه</span>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-success" onclick="saveBoard()">💾 ذخیره</button>
<button class="btn btn-ghost" onclick="closeModal('boardModal')">❌ انصراف</button>
</div>
</div>
</div>
<!-- ========== Modal: Column ========== -->
<div class="modal-overlay" id="columnModal" role="dialog" aria-modal="true" aria-labelledby="columnModalTitle">
<div class="modal" style="max-width:500px;">
<div class="modal-header">
<div class="modal-title">📊 <span id="columnModalTitle">ایجاد ستون جدید</span></div>
<button class="modal-close" onclick="closeModal('columnModal')" aria-label="بستن">✕</button>
</div>
<div class="modal-body">
<div class="form-group">
<label class="form-label">عنوان ستون <span class="required">*</span></label>
<input type="text" class="form-input" id="columnTitle" placeholder="مثلاً: در حال انجام">
<div class="form-error" id="columnTitleError" style="display:none;"></div>
</div>
<div class="form-group">
<label class="form-label">🎨 رنگ پس‌زمینه ستون</label>
<div class="color-picker-row" id="columnBgColors"></div>
<div class="color-picker-custom">
<input type="color" id="columnBgCustom" value="#edf2f7" onchange="selectCustomColor('column', 'bg', this.value)">
<span style="font-size:12px;color:var(--text-muted);">انتخاب رنگ دلخواه</span>
</div>
</div>
<div class="form-group">
<label class="form-label">🖋️ رنگ متن ستون</label>
<div class="color-picker-row" id="columnTextColors"></div>
<div class="color-picker-custom">
<input type="color" id="columnTextCustom" value="#1a202c" onchange="selectCustomColor('column', 'text', this.value)">
<span style="font-size:12px;color:var(--text-muted);">انتخاب رنگ دلخواه</span>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-success" onclick="saveColumn()">💾 ذخیره</button>
<button class="btn btn-ghost" onclick="closeModal('columnModal')">❌ انصراف</button>
</div>
</div>
</div>
<!-- ========== Modal: Card ========== -->
<div class="modal-overlay" id="cardModal" role="dialog" aria-modal="true" aria-labelledby="cardModalTitle">
<div class="modal modal-lg">
<div class="modal-header">
<div class="modal-title">📝 <span id="cardModalTitle">ایجاد کارت جدید</span></div>
<button class="modal-close" onclick="closeModal('cardModal')" aria-label="بستن">✕</button>
</div>
<div class="modal-body">
<div class="form-group">
<label class="form-label">عنوان کارت <span class="required">*</span></label>
<input type="text" class="form-input" id="cardTitle" placeholder="عنوان وظیفه...">
</div>
<div class="form-group">
<label class="form-label">توضیحات <span class="required">*</span></label>
<textarea class="form-textarea" id="cardDesc" placeholder="توضیحات کامل وظیفه..."></textarea>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">📅 تاریخ و زمان تخمینی انجام</label>
<input type="datetime-local" class="form-input" id="cardDate">
<div class="form-hint">تاریخ و ساعت تقریبی پایان وظیفه</div>
</div>
<div class="form-group">
<label class="form-label">🎨 رنگ پس‌زمینه کارت</label>
<div class="color-picker-row" id="cardBgColors"></div>
<div class="color-picker-custom">
<input type="color" id="cardBgCustom" value="#ffffff" onchange="selectCustomColor('card', 'bg', this.value)">
</div>
</div>
</div>
<div class="form-group">
<label class="form-label">👥 افراد مسئول</label>
<div class="assignees-container" id="assigneesContainer">
<div class="empty-state" style="padding:10px;">
<div style="font-size:12px;">هنوز کسی اضافه نشده</div>
</div>
</div>
<div class="add-checklist-row" style="margin-top:6px;">
<input type="text" class="form-input" id="newAssigneeInput" placeholder="نام یا نام کاربری...">
<button class="btn btn-sm" onclick="addAssignee()">➕</button>
</div>
</div>
<div class="form-group">
<label class="form-label">🏷️ برچسب‌ها</label>
<div class="tags-container" id="cardTagsContainer">
<div class="empty-state" style="padding:10px;width:100%;">
<div style="font-size:12px;">تگی وجود ندارد. از منوی اصلی تگ ایجاد کنید.</div>
</div>
</div>
</div>
<div class="form-group">
<label class="form-label">🔗 وابستگی‌ها (Dependencies)</label>
<div class="dep-list" id="depList">
<div class="empty-state" style="padding:10px;">
<div style="font-size:12px;">وابستگی انتخاب نشده</div>
</div>
</div>
<div class="add-checklist-row" style="margin-top:6px;">
<select class="form-select" id="depSelect"></select>
<button class="btn btn-sm" onclick="addDependency()">➕</button>
</div>
</div>
<div class="form-group">
<label class="form-label">✅ چک‌لیست ریزوظایف</label>
<div class="checklist-items" id="checklistContainer">
<div class="empty-state" style="padding:10px;">
<div style="font-size:12px;">آیتمی وجود ندارد</div>
</div>
</div>
<div class="add-checklist-row" style="margin-top:6px;">
<input type="text" class="form-input" id="newChecklistInput" placeholder="ریزوظیفه جدید...">
<button class="btn btn-sm" onclick="addChecklistItem()">➕</button>
</div>
</div>
<div class="form-group">
<label class="form-label">📜 تاریخچه جابجایی</label>
<div class="history-list" id="historyList">
<div class="empty-state" style="padding:10px;">
<div style="font-size:12px;">هنوز تاریخچه‌ای ثبت نشده</div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-success" onclick="saveCard()">💾 ذخیره</button>
<button class="btn btn-danger" id="deleteCardBtn" onclick="deleteCard()" style="display:none;">🗑️ حذف</button>
<button class="btn btn-ghost" onclick="closeModal('cardModal')">❌ انصراف</button>
</div>
</div>
</div>
<!-- ========== Modal: Project Settings ========== -->
<div class="modal-overlay" id="projectModal" role="dialog" aria-modal="true">
<div class="modal" style="max-width:600px;">
<div class="modal-header">
<div class="modal-title">⚙️ تنظیمات پروژه</div>
<button class="modal-close" onclick="closeModal('projectModal')" aria-label="بستن">✕</button>
</div>
<div class="modal-body">
<div class="form-group">
<label class="form-label">عنوان پروژه <span class="required">*</span></label>
<input type="text" class="form-input" id="projectTitle">
</div>
<div class="form-group">
<label class="form-label">توضیحات</label>
<textarea class="form-textarea" id="projectDesc"></textarea>
</div>
<div class="form-group">
<label class="form-label">تاریخ ایجاد</label>
<input type="text" class="form-input" id="projectCreatedAt" disabled>
</div>
<!-- Webhook Configuration -->
<div class="webhook-section">
<div class="webhook-section-header">
<div class="webhook-section-title">
<span>🔗</span>
<span>وب‌هوک (Webhook)</span>
</div>
<div class="webhook-toggle">
<span>فعال</span>
<label class="switch">
<input type="checkbox" id="webhookEnabled" checked>
<span class="slider"></span>
</label>
</div>
</div>
<div class="form-hint" style="margin-bottom:8px;">
آدرس URL که رویدادهای پروژه به آن ارسال می‌شوند. در صورت خالی بودن، وب‌هوک غیرفعال است.
</div>
<div class="webhook-url-row">
<input type="url" class="form-input" id="webhookUrl" placeholder="https://example.com/webhook" dir="ltr">
<button class="btn btn-info btn-sm" onclick="testWebhook()" id="testWebhookBtn">🧪 تست</button>
</div>
<div class="webhook-status" id="webhookStatus" style="display:none;">
<div class="webhook-status-dot" id="webhookStatusDot"></div>
<div id="webhookStatusText" style="flex:1;"></div>
<div id="webhookStatusTime" style="font-size:10px;color:var(--text-muted);"></div>
</div>
<div class="info-box" style="margin-top:12px;margin-bottom:0;font-size:11px;">
<strong>📡 ساختار پیام:</strong> تمام عملیات‌ها با ساختار استاندارد JSON شامل eventId، timestamp، eventType، project، board و data ارسال می‌شوند.
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-success" onclick="saveProjectSettings()">💾 ذخیره</button>
<button class="btn btn-ghost" onclick="closeModal('projectModal')">❌ انصراف</button>
</div>
</div>
</div>
<!-- ========== Modal: Tag Manager ========== -->
<div class="modal-overlay" id="tagModal" role="dialog" aria-modal="true">
<div class="modal">
<div class="modal-header">
<div class="modal-title">🏷️ مدیریت تگ‌های عمومی</div>
<button class="modal-close" onclick="closeModal('tagModal')" aria-label="بستن">✕</button>
</div>
<div class="modal-body">
<div class="form-row">
<div class="form-group">
<label class="form-label">نام تگ</label>
<input type="text" class="form-input" id="newTagName" placeholder="مثلاً: فوری">
</div>
<div class="form-group">
<label class="form-label">رنگ</label>
<input type="color" class="form-input" id="newTagColor" value="#c9a961" style="height:38px;">
</div>
</div>
<button class="btn btn-success btn-sm" onclick="addTag()">➕ افزودن تگ</button>
<div style="margin-top:20px;">
<label class="form-label">تگ‌های موجود</label>
<div class="tag-manage-list" id="tagManageList"></div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-ghost" onclick="closeModal('tagModal')">بستن</button>
</div>
</div>
</div>
<!-- ========== Modal: Save ========== -->
<div class="modal-overlay" id="saveModal" role="dialog" aria-modal="true">
<div class="modal" style="max-width:450px;">
<div class="modal-header">
<div class="modal-title">💾 ذخیره پروژه</div>
<button class="modal-close" onclick="closeModal('saveModal')" aria-label="بستن">✕</button>
</div>
<div class="modal-body">
<div class="form-group">
<label class="form-label">نام فایل</label>
<input type="text" class="form-input" id="saveFileName" value="xkanban-project.json">
</div>
<div class="info-box">
<strong>ℹ️ توجه:</strong> فایل JSON در پوشه دانلودهای مرورگر ذخیره می‌شود.
</div>
</div>
<div class="modal-footer">
<button class="btn btn-success" onclick="downloadProject()">💾 دانلود فایل</button>
<button class="btn btn-ghost" onclick="closeModal('saveModal')">انصراف</button>
</div>
</div>
</div>
<!-- ========== Modal: Export ========== -->
<div class="modal-overlay" id="exportModal" role="dialog" aria-modal="true">
<div class="modal" style="max-width:500px;">
<div class="modal-header">
<div class="modal-title">📤 خروجی گرفتن از پروژه</div>
<button class="modal-close" onclick="closeModal('exportModal')" aria-label="بستن">✕</button>
</div>
<div class="modal-body">
<div class="form-group">
<label class="form-label">انتخاب برد</label>
<select class="form-select" id="exportBoardSelect">
<option value="all">همه بردها</option>
</select>
</div>
<div class="form-group">
<label class="form-label">فرمت خروجی</label>
<select class="form-select" id="exportFormat">
<option value="markdown">📝 Markdown (MD)</option>
<option value="csv">📊 CSV</option>
</select>
</div>
<div class="info-box">
<strong>ℹ️ توجه:</strong> فایل در پوشه دانلودهای مرورگر ذخیره می‌شود.
</div>
</div>
<div class="modal-footer">
<button class="btn btn-success" onclick="exportProject()">📤 خروجی</button>
<button class="btn btn-ghost" onclick="closeModal('exportModal')">انصراف</button>
</div>
</div>
</div>
<!-- ========== Modal: Autosave Recovery ========== -->
<div class="modal-overlay" id="autosaveModal" role="dialog" aria-modal="true">
<div class="modal" style="max-width:450px;">
<div class="modal-header">
<div class="modal-title">💾 بازیابی خودکار</div>
<button class="modal-close" onclick="closeModal('autosaveModal')" aria-label="بستن">✕</button>
</div>
<div class="modal-body">
<div class="info-box">
<strong>⚠️ داده ذخیره‌شده خودکار یافت شد</strong><br>
به نظر می‌رسد در جلسه قبلی، تغییراتی ذخیره شده ولی به فایل خروجی منتقل نشده‌اند.
<div style="margin-top:10px;font-size:12px;color:var(--text-muted);">
تاریخ ذخیره: <span id="autosaveDate"></span>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-success" onclick="recoverAutosave()">💾 بازیابی</button>
<button class="btn btn-danger" onclick="discardAutosave()">🗑️ نادیده گرفتن</button>
</div>
</div>
</div>
<div class="toast-container" id="toastContainer" aria-live="polite"></div>
<input type="file" id="fileInput" accept=".json" style="display:none;">
<script>
/* ============================================================
xKanban - Core Application
Architect: Hadi Khazaee Asl
Version: 1.0.0
Features:
- Command Pattern for Undo/Redo
- Auto-save with recovery
- Event Delegation for XSS prevention
- Reference cleanup on delete
- Search & Filter
- Statistics Dashboard
- Export to Markdown/CSV
- Keyboard Navigation
- ARIA Accessibility
- Migration Strategy
- Webhook Integration
============================================================ */
const APP_VERSION = '1.0.0';
const AUTOSAVE_INTERVAL = 30000;
const AUTOSAVE_KEY = 'xkanban-autosave';
const WEBHOOK_STORAGE_KEY = 'xkanban-webhook';
// ============ Event Types ============
const EVENT_TYPES = {
PROJECT_UPDATED: 'project.updated',
PROJECT_LOADED: 'project.loaded',
PROJECT_SAVED: 'project.saved',
BOARD_CREATED: 'board.created',
BOARD_UPDATED: 'board.updated',
BOARD_DELETED: 'board.deleted',
COLUMN_CREATED: 'column.created',
COLUMN_UPDATED: 'column.updated',
COLUMN_DELETED: 'column.deleted',
COLUMN_REORDERED: 'column.reordered',
CARD_CREATED: 'card.created',
CARD_UPDATED: 'card.updated',
CARD_DELETED: 'card.deleted',
CARD_MOVED: 'card.moved',
TAG_CREATED: 'tag.created',
TAG_DELETED: 'tag.deleted',
WEBHOOK_TEST: 'webhook.test'
};
// ============ State Management ============
const AppState = {
project: {
id: generateId(),
title: 'پروژه جدید xKanban',
description: '',
createdAt: new Date().toISOString(),
boards: []
},
tags: [],
activeBoardId: null,
editing: { boardId: null, columnId: null, cardId: null },
tempData: {},
search: { query: '', tag: '', assignee: '', status: '' },
keyboard: { focusedCardId: null },
webhook: {
url: '',
enabled: true,
lastStatus: null,
lastSent: null,
lastError: null,
stats: { total: 0, success: 0, failed: 0 }
}
};
// ============ Utilities ============
function generateId() {
return 'id_' + Date.now().toString(36) + '_' + Math.random().toString(36).substr(2, 9);
}
function formatDate(iso) {
if (!iso) return '—';
const d = new Date(iso);
return d.toLocaleDateString('fa-IR') + ' ' + d.toLocaleTimeString('fa-IR', {hour:'2-digit', minute:'2-digit'});
}
function formatDateShort(iso) {
if (!iso) return '';
const d = new Date(iso);
return d.toLocaleDateString('fa-IR') + ' ' + d.toLocaleTimeString('fa-IR', {hour:'2-digit', minute:'2-digit'});
}
function escapeHtml(str) {
if (!str) return '';
return String(str).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
function showToast(message, type = 'info') {
const container = document.getElementById('toastContainer');
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.setAttribute('role', 'alert');
const icons = { success: '✅', error: '❌', warning: '⚠️', info: 'ℹ️' };
toast.innerHTML = `<span>${icons[type] || 'ℹ️'}</span><span>${escapeHtml(message)}</span>`;
container.appendChild(toast);
setTimeout(() => {
toast.style.opacity = '0';
toast.style.transform = 'translateX(-30px)';
setTimeout(() => toast.remove(), 300);
}, 3000);
}
const COLOR_PALETTE = [
'#ffffff', '#f7fafc', '#edf2f7', '#e2e8f0',
'#fef5e7', '#fffaf0', '#fefcbf',
'#f0fff4', '#e6fffa', '#c6f6d5',
'#ebf8ff', '#bee3f8', '#90cdf4',
'#faf5ff', '#e9d8fd', '#d6bcfa',
'#fed7d7', '#feb2b2', '#fc8181',
'#1a365d', '#2c5282', '#2b6cb0',
'#1a202c', '#2d3748', '#4a5568'
];
const TEXT_COLORS = ['#1a202c', '#ffffff', '#2c5282', '#744210', '#22543d', '#553c9a', '#742a2a', '#c9a961', '#e53e3e', '#38a169'];
// ============ Webhook Manager ============
const WebhookManager = {
async send(eventType, data, options = {}) {
const url = AppState.webhook.url;
if (!url || !AppState.webhook.enabled) {
return { skipped: true, reason: !url ? 'no-url' : 'disabled' };
}
const board = getActiveBoard();
const message = {
eventId: generateId(),
timestamp: new Date().toISOString(),
eventType: eventType,
version: APP_VERSION,
source: 'xKanban',
project: {
id: AppState.project.id,
title: AppState.project.title
},
board: board ? {
id: board.id,
title: board.title
} : null,
data: data || {},
metadata: {
userAgent: navigator.userAgent,
language: navigator.language,
screenResolution: `${screen.width}x${screen.height}`,
triggeredBy: options.triggeredBy || 'user-action'
}
};
this.showIndicator('pending', 'در حال ارسال...');
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Webhook-Source': 'xKanban',
'X-Webhook-Event': eventType,
'X-Webhook-Version': APP_VERSION
},
body: JSON.stringify(message),
keepalive: true,
signal: controller.signal
});
clearTimeout(timeoutId);
AppState.webhook.lastStatus = response.ok ? 'success' : 'error';
AppState.webhook.lastSent = new Date().toISOString();
AppState.webhook.lastError = response.ok ? null : `HTTP ${response.status}`;
AppState.webhook.stats.total++;
if (response.ok) AppState.webhook.stats.success++;
else AppState.webhook.stats.failed++;
this.saveToStorage();
this.showIndicator(response.ok ? 'success' : 'error',
response.ok ? `✓ ${eventType}` : `✗ خطای ${response.status}`);
this.updateStatusUI();
return { success: response.ok, status: response.status, message };
} catch (err) {
AppState.webhook.lastStatus = 'error';
AppState.webhook.lastSent = new Date().toISOString();
AppState.webhook.lastError = err.name === 'AbortError' ? 'Timeout' : err.message;
AppState.webhook.stats.total++;
AppState.webhook.stats.failed++;
this.saveToStorage();
this.showIndicator('error', `✗ خطا: ${AppState.webhook.lastError}`);
this.updateStatusUI();
return { success: false, error: err.message, message };
}
},
showIndicator(status, text) {
const indicator = document.getElementById('webhookIndicator');
const dot = document.getElementById('webhookIndicatorDot');
const textEl = document.getElementById('webhookIndicatorText');
indicator.className = `webhook-indicator visible ${status}`;
dot.className = `webhook-status-dot ${status}`;
textEl.textContent = text;
setTimeout(() => {
indicator.classList.remove('visible');
}, 2500);
},
updateStatusUI() {
const statusEl = document.getElementById('webhookStatus');
const dotEl = document.getElementById('webhookStatusDot');
const textEl = document.getElementById('webhookStatusText');
const timeEl = document.getElementById('webhookStatusTime');
if (!AppState.webhook.lastSent) {
statusEl.style.display = 'none';
return;
}
statusEl.style.display = 'flex';
dotEl.className = `webhook-status-dot ${AppState.webhook.lastStatus || ''}`;
const stats = AppState.webhook.stats;
if (AppState.webhook.lastStatus === 'success') {
textEl.textContent = `✓ ارسال موفق (موفق: ${stats.success} | خطا: ${stats.failed})`;
} else if (AppState.webhook.lastStatus === 'error') {
textEl.textContent = `✗ ${AppState.webhook.lastError} (موفق: ${stats.success} | خطا: ${stats.failed})`;
} else {
textEl.textContent = `در انتظار...`;
}
timeEl.textContent = formatDate(AppState.webhook.lastSent);
},
saveToStorage() {
try {
localStorage.setItem(WEBHOOK_STORAGE_KEY, JSON.stringify({
url: AppState.webhook.url,
enabled: AppState.webhook.enabled,
stats: AppState.webhook.stats,
lastStatus: AppState.webhook.lastStatus,
lastSent: AppState.webhook.lastSent,
lastError: AppState.webhook.lastError
}));
} catch (e) {
console.error('Webhook storage failed:', e);
}
},
loadFromStorage() {
try {
const saved = localStorage.getItem(WEBHOOK_STORAGE_KEY);
if (saved) {
const data = JSON.parse(saved);
AppState.webhook.url = data.url || '';
AppState.webhook.enabled = data.enabled !== false;
AppState.webhook.stats = data.stats || { total: 0, success: 0, failed: 0 };
AppState.webhook.lastStatus = data.lastStatus || null;
AppState.webhook.lastSent = data.lastSent || null;
AppState.webhook.lastError = data.lastError || null;
}
} catch (e) {
console.error('Webhook load failed:', e);
}
},
async test() {
const result = await this.send(EVENT_TYPES.WEBHOOK_TEST, {
message: 'این یک پیام تست از xKanban است',
testTime: new Date().toISOString()
}, { triggeredBy: 'manual-test' });
if (result.skipped) {
showToast(result.reason === 'no-url' ? 'آدرس وب‌هوک تنظیم نشده است' : 'وب‌هوک غیرفعال است', 'warning');
return;
}
if (result.success) {
showToast('✓ تست وب‌هوک موفقیت‌آمیز بود', 'success');
} else {
showToast(`✗ خطا در تست وب‌هوک: ${result.error || result.status}`, 'error');
}
}
};
// ============ Command Pattern (Undo/Redo) ============
const CommandHistory = {
undoStack: [],
redoStack: [],
maxHistory: 50,
isExecuting: false,
async execute(command) {
if (this.isExecuting) return;
try {
this.isExecuting = true;
command.execute();
this.undoStack.push(command);
if (this.undoStack.length > this.maxHistory) {
this.undoStack.shift();
}
this.redoStack = [];
this.updateButtons();
AutoSaveManager.scheduleSave();
// Send webhook
if (command.eventType) {
const eventData = typeof command.getEventData === 'function' ? command.getEventData() : {};
WebhookManager.send(command.eventType, eventData, { triggeredBy: 'command-execute' });
}
} finally {
this.isExecuting = false;
}
},
async undo() {
const cmd = this.undoStack.pop();
if (!cmd) return;
try {
this.isExecuting = true;
cmd.undo();
this.redoStack.push(cmd);
this.updateButtons();
renderBoard();
renderTabs();
showToast('عملیات بازگشت انجام شد', 'info');
// Send webhook
if (cmd.eventType) {
WebhookManager.send(cmd.eventType + '.undone',
typeof cmd.getEventData === 'function' ? cmd.getEventData() : {},
{ triggeredBy: 'undo' });
}
} finally {
this.isExecuting = false;
}
},
async redo() {
const cmd = this.redoStack.pop();
if (!cmd) return;
try {
this.isExecuting = true;
cmd.execute();
this.undoStack.push(cmd);
this.updateButtons();
renderBoard();
renderTabs();
showToast('عملیات از نو انجام شد', 'info');
// Send webhook
if (cmd.eventType) {
WebhookManager.send(cmd.eventType + '.redone',
typeof cmd.getEventData === 'function' ? cmd.getEventData() : {},
{ triggeredBy: 'redo' });
}
} finally {
this.isExecuting = false;
}
},
updateButtons() {
document.getElementById('undoBtn').disabled = this.undoStack.length === 0;
document.getElementById('redoBtn').disabled = this.redoStack.length === 0;
},
clear() {
this.undoStack = [];
this.redoStack = [];
this.updateButtons();
}
};
// Command classes with webhook event types
class CreateBoardCommand {
constructor(boardData) {
this.boardData = boardData;
this.boardId = null;
}
get eventType() { return EVENT_TYPES.BOARD_CREATED; }
execute() {
const board = {
id: generateId(),
...this.boardData,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
columns: []
};
this.boardId = board.id;
AppState.project.boards.push(board);
AppState.activeBoardId = board.id;
}
undo() {
AppState.project.boards = AppState.project.boards.filter(b => b.id !== this.boardId);
if (AppState.activeBoardId === this.boardId) {
AppState.activeBoardId = AppState.project.boards[0]?.id || null;
}
}
getEventData() {
return {
boardId: this.boardId,
title: this.boardData.title,
description: this.boardData.description
};
}
}
class UpdateBoardCommand {
constructor(boardId, oldData, newData) {
this.boardId = boardId;
this.oldData = oldData;
this.newData = newData;
}
get eventType() { return EVENT_TYPES.BOARD_UPDATED; }
execute() {
const board = AppState.project.boards.find(b => b.id === this.boardId);
if (board) Object.assign(board, this.newData, { updatedAt: new Date().toISOString() });
}
undo() {
const board = AppState.project.boards.find(b => b.id === this.boardId);
if (board) Object.assign(board, this.oldData, { updatedAt: new Date().toISOString() });
}
getEventData() {
return { boardId: this.boardId, changes: this.newData };
}
}
class DeleteBoardCommand {
constructor(boardId) {
this.boardId = boardId;
this.boardData = null;
this.previousIndex = -1;
}
get eventType() { return EVENT_TYPES.BOARD_DELETED; }
execute() {
const idx = AppState.project.boards.findIndex(b => b.id === this.boardId);
if (idx >= 0) {
this.boardData = AppState.project.boards[idx];
this.previousIndex = idx;
AppState.project.boards.splice(idx, 1);
if (AppState.activeBoardId === this.boardId) {
AppState.activeBoardId = AppState.project.boards[0]?.id || null;
}
}
}
undo() {
if (this.boardData) {
AppState.project.boards.splice(this.previousIndex, 0, this.boardData);
AppState.activeBoardId = this.boardId;
}
}
getEventData() {
return {
boardId: this.boardId,
title: this.boardData?.title
};
}
}
class CreateColumnCommand {
constructor(boardId, columnData) {
this.boardId = boardId;
this.columnData = columnData;
this.columnId = null;
}
get eventType() { return EVENT_TYPES.COLUMN_CREATED; }
execute() {
const board = AppState.project.boards.find(b => b.id === this.boardId);
if (board) {
const col = { id: generateId(), ...this.columnData, cards: [] };
this.columnId = col.id;
board.columns.push(col);
board.updatedAt = new Date().toISOString();
}
}
undo() {
const board = AppState.project.boards.find(b => b.id === this.boardId);
if (board) {
board.columns = board.columns.filter(c => c.id !== this.columnId);
board.updatedAt = new Date().toISOString();
}
}
getEventData() {
return {
boardId: this.boardId,
columnId: this.columnId,
title: this.columnData.title
};
}
}
class UpdateColumnCommand {
constructor(boardId, columnId, oldData, newData) {
this.boardId = boardId;
this.columnId = columnId;
this.oldData = oldData;
this.newData = newData;
}
get eventType() { return EVENT_TYPES.COLUMN_UPDATED; }
execute() {
const board = AppState.project.boards.find(b => b.id === this.boardId);
if (board) {
const col = board.columns.find(c => c.id === this.columnId);
if (col) Object.assign(col, this.newData);
board.updatedAt = new Date().toISOString();
}
}
undo() {
const board = AppState.project.boards.find(b => b.id === this.boardId);
if (board) {
const col = board.columns.find(c => c.id === this.columnId);
if (col) Object.assign(col, this.oldData);
board.updatedAt = new Date().toISOString();
}
}
getEventData() {
return {
boardId: this.boardId,
columnId: this.columnId,
changes: this.newData
};
}
}
class DeleteColumnCommand {
constructor(boardId, columnId) {
this.boardId = boardId;
this.columnId = columnId;
this.columnData = null;
this.previousIndex = -1;
}
get eventType() { return EVENT_TYPES.COLUMN_DELETED; }
execute() {
const board = AppState.project.boards.find(b => b.id === this.boardId);
if (board) {
const idx = board.columns.findIndex(c => c.id === this.columnId);
if (idx >= 0) {
this.columnData = board.columns[idx];
this.previousIndex = idx;
board.columns.splice(idx, 1);
board.updatedAt = new Date().toISOString();
}
}
}
undo() {
const board = AppState.project.boards.find(b => b.id === this.boardId);
if (board && this.columnData) {
board.columns.splice(this.previousIndex, 0, this.columnData);
board.updatedAt = new Date().toISOString();
}
}
getEventData() {
return {
boardId: this.boardId,
columnId: this.columnId,
title: this.columnData?.title,
cardsCount: this.columnData?.cards?.length || 0
};
}
}
class CreateCardCommand {
constructor(columnId, cardData) {
this.columnId = columnId;
this.cardData = cardData;
this.cardId = null;
this.boardId = null;
}
get eventType() { return EVENT_TYPES.CARD_CREATED; }
execute() {
const { column, board } = findCardContext(null, this.columnId);
if (column && board) {
this.boardId = board.id;
const card = {
id: generateId(),
...this.cardData,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
};
this.cardId = card.id;
column.cards.push(card);
board.updatedAt = new Date().toISOString();
}
}
undo() {
const { column, board } = findCardContext(null, this.columnId);
if (column && board) {
column.cards = column.cards.filter(c => c.id !== this.cardId);
board.updatedAt = new Date().toISOString();
}
}
getEventData() {
const col = findColumnById(this.columnId);
return {
boardId: this.boardId,
columnId: this.columnId,
columnTitle: col?.title,
cardId: this.cardId,
title: this.cardData.title
};
}
}
class UpdateCardCommand {
constructor(cardId, oldData, newData) {
this.cardId = cardId;
this.oldData = oldData;
this.newData = newData;
}
get eventType() { return EVENT_TYPES.CARD_UPDATED; }
execute() {
const { card, board } = findCard(this.cardId);
if (card) {
Object.assign(card, this.newData, { updatedAt: new Date().toISOString() });
if (board) board.updatedAt = new Date().toISOString();
}
}
undo() {
const { card, board } = findCard(this.cardId);
if (card) {
Object.assign(card, this.oldData, { updatedAt: new Date().toISOString() });
if (board) board.updatedAt = new Date().toISOString();
}
}
getEventData() {
const { card, column } = findCard(this.cardId);
return {
cardId: this.cardId,
columnId: column?.id,
columnTitle: column?.title,
title: card?.title,
changes: Object.keys(this.newData)
};
}
}
class DeleteCardCommand {
constructor(cardId) {
this.cardId = cardId;
this.cardData = null;
this.columnId = null;
this.previousIndex = -1;
this.boardId = null;
}
get eventType() { return EVENT_TYPES.CARD_DELETED; }
execute() {
const { card, column, board } = findCard(this.cardId);
if (card && column && board) {
this.cardData = JSON.parse(JSON.stringify(card));
this.columnId = column.id;
this.boardId = board.id;
this.previousIndex = column.cards.findIndex(c => c.id === this.cardId);
column.cards = column.cards.filter(c => c.id !== this.cardId);
ReferenceManager.cleanupCardReferences(this.cardId);
board.updatedAt = new Date().toISOString();
}
}
undo() {
const board = AppState.project.boards.find(b => b.id === this.boardId);
if (board) {
const column = board.columns.find(c => c.id === this.columnId);
if (column && this.cardData) {
column.cards.splice(this.previousIndex, 0, this.cardData);
board.updatedAt = new Date().toISOString();
}
}
}
getEventData() {
return {
boardId: this.boardId,
columnId: this.columnId,
cardId: this.cardId,
title: this.cardData?.title
};
}
}
class MoveCardCommand {
constructor(cardId, fromColumnId, toColumnId, fromIndex, toIndex) {
this.cardId = cardId;
this.fromColumnId = fromColumnId;
this.toColumnId = toColumnId;
this.fromIndex = fromIndex;
this.toIndex = toIndex;
}
get eventType() { return EVENT_TYPES.CARD_MOVED; }
execute() {
const board = getActiveBoard();
if (!board) return;
const fromCol = board.columns.find(c => c.id === this.fromColumnId);
const toCol = board.columns.find(c => c.id === this.toColumnId);
if (!fromCol || !toCol) return;
const cardIdx = fromCol.cards.findIndex(c => c.id === this.cardId);
if (cardIdx < 0) return;
const [card] = fromCol.cards.splice(cardIdx, 1);
if (this.fromColumnId !== this.toColumnId) {
card.history = card.history || [];
card.history.push({
from: fromCol.title,
to: toCol.title,
date: new Date().toISOString()
});
card.updatedAt = new Date().toISOString();
}
toCol.cards.splice(this.toIndex, 0, card);
board.updatedAt = new Date().toISOString();
}
undo() {
const board = getActiveBoard();
if (!board) return;
const fromCol = board.columns.find(c => c.id === this.fromColumnId);
const toCol = board.columns.find(c => c.id === this.toColumnId);
if (!fromCol || !toCol) return;
const cardIdx = toCol.cards.findIndex(c => c.id === this.cardId);
if (cardIdx < 0) return;
const [card] = toCol.cards.splice(cardIdx, 1);
if (this.fromColumnId !== this.toColumnId && card.history && card.history.length > 0) {
card.history.pop();
}
fromCol.cards.splice(this.fromIndex, 0, card);
board.updatedAt = new Date().toISOString();
}
getEventData() {
const board = getActiveBoard();
const fromCol = board?.columns.find(c => c.id === this.fromColumnId);
const toCol = board?.columns.find(c => c.id === this.toColumnId);
const card = findCardById(this.cardId);
return {
cardId: this.cardId,
cardTitle: card?.title,
fromColumn: { id: this.fromColumnId, title: fromCol?.title },
toColumn: { id: this.toColumnId, title: toCol?.title },
fromIndex: this.fromIndex,
toIndex: this.toIndex,
crossColumn: this.fromColumnId !== this.toColumnId
};
}
}
class ReorderColumnsCommand {
constructor(boardId, fromIdx, toIdx) {
this.boardId = boardId;
this.fromIdx = fromIdx;
this.toIdx = toIdx;
}
get eventType() { return EVENT_TYPES.COLUMN_REORDERED; }
execute() {
const board = AppState.project.boards.find(b => b.id === this.boardId);
if (!board) return;
const [moved] = board.columns.splice(this.fromIdx, 1);
board.columns.splice(this.toIdx, 0, moved);
board.updatedAt = new Date().toISOString();
}
undo() {
const board = AppState.project.boards.find(b => b.id === this.boardId);
if (!board) return;
const [moved] = board.columns.splice(this.toIdx, 1);
board.columns.splice(this.fromIdx, 0, moved);
board.updatedAt = new Date().toISOString();
}
getEventData() {
return {
boardId: this.boardId,
fromIndex: this.fromIdx,
toIndex: this.toIdx
};
}
}
class AddTagCommand {
constructor(tagData) {
this.tagData = tagData;
this.tagId = null;
}
get eventType() { return EVENT_TYPES.TAG_CREATED; }
execute() {
const tag = { id: generateId(), ...this.tagData };
this.tagId = tag.id;
AppState.tags.push(tag);
}
undo() {
AppState.tags = AppState.tags.filter(t => t.id !== this.tagId);
AppState.project.boards.forEach(board => {
board.columns.forEach(col => {
col.cards.forEach(card => {
card.tags = (card.tags || []).filter(tid => tid !== this.tagId);
});
});
});
}
getEventData() {
return {
tagId: this.tagId,
name: this.tagData.name,
color: this.tagData.color
};
}
}
class DeleteTagCommand {
constructor(tagId) {
this.tagId = tagId;
this.tagData = null;
this.cardReferences = [];
}
get eventType() { return EVENT_TYPES.TAG_DELETED; }
execute() {
const tag = AppState.tags.find(t => t.id === this.tagId);
if (tag) {
this.tagData = tag;
AppState.tags = AppState.tags.filter(t => t.id !== this.tagId);
AppState.project.boards.forEach(board => {
board.columns.forEach(col => {
col.cards.forEach(card => {
if ((card.tags || []).includes(this.tagId)) {
this.cardReferences.push({ cardId: card.id, tagId: this.tagId });
card.tags = card.tags.filter(tid => tid !== this.tagId);
}
});
});
});
}
}
undo() {
if (this.tagData) {
AppState.tags.push(this.tagData);
this.cardReferences.forEach(ref => {
const { card } = findCard(ref.cardId);
if (card) {
card.tags = card.tags || [];
card.tags.push(ref.tagId);
}
});
}
}
getEventData() {
return {
tagId: this.tagId,
name: this.tagData?.name,
affectedCards: this.cardReferences.length
};
}
}
// ============ Reference Manager ============
const ReferenceManager = {
cleanupCardReferences(deletedCardId) {
AppState.project.boards.forEach(board => {
board.columns.forEach(col => {
col.cards.forEach(card => {
if (card.dependencies && card.dependencies.includes(deletedCardId)) {
card.dependencies = card.dependencies.filter(d => d !== deletedCardId);
}
});
});
});
},
cleanupTagReferences(deletedTagId) {
AppState.project.boards.forEach(board => {
board.columns.forEach(col => {
col.cards.forEach(card => {
if (card.tags && card.tags.includes(deletedTagId)) {
card.tags = card.tags.filter(t => t !== deletedTagId);
}
});
});
});
}
};
// ============ Auto-Save Manager ============
const AutoSaveManager = {
saveTimer: null,
lastSaveTime: null,
scheduleSave() {
if (this.saveTimer) clearTimeout(this.saveTimer);
this.saveTimer = setTimeout(() => this.save(), AUTOSAVE_INTERVAL);
},
save() {
try {
const data = {
version: APP_VERSION,
project: AppState.project,
tags: AppState.tags,
activeBoardId: AppState.activeBoardId,
savedAt: new Date().toISOString()
};
localStorage.setItem(AUTOSAVE_KEY, JSON.stringify(data));
this.lastSaveTime = new Date();
this.showIndicator();
} catch (e) {
console.error('Auto-save failed:', e);
}
},
showIndicator() {
const indicator = document.getElementById('autosaveIndicator');
const text = document.getElementById('autosaveText');
text.textContent = 'ذخیره خودکار انجام شد';
indicator.classList.add('visible');
setTimeout(() => indicator.classList.remove('visible'), 2000);
},
load() {
const saved = localStorage.getItem(AUTOSAVE_KEY);
if (!saved) return null;
try {
return JSON.parse(saved);
} catch (e) {
return null;
}
},
clear() {
localStorage.removeItem(AUTOSAVE_KEY);
}
};
// ============ Migration Manager ============
const MigrationManager = {
migrations: {
'1.0.0': (data) => data,
},
migrate(data) {
if (!data.version) data.version = '1.0.0';
const versions = Object.keys(this.migrations).sort();
const currentIdx = versions.indexOf(data.version);
for (let i = currentIdx + 1; i < versions.length; i++) {
const version = versions[i];
data = this.migrations[version](data);
data.version = version;
}
return data;
}
};
// ============ Statistics Manager ============
const StatsManager = {
calculate() {
const board = getActiveBoard();
if (!board) return { total: 0, done: 0, soon: 0, overdue: 0, progress: 0 };
let total = 0, done = 0, soon = 0, overdue = 0;
const now = new Date();
board.columns.forEach((col, idx) => {
col.cards.forEach(card => {
total++;
if (idx === 0) done++;
if (card.estimatedDate) {
const due = new Date(card.estimatedDate);
const diffDays = (due - now) / (1000 * 60 * 60 * 24);
if (diffDays < 0) overdue++;
else if (diffDays <= 3) soon++;
}
});
});
const progress = total > 0 ? Math.round((done / total) * 100) : 0;
return { total, done, soon, overdue, progress };
},
update() {
const stats = this.calculate();
document.getElementById('statTotal').textContent = stats.total.toLocaleString('fa-IR');
document.getElementById('statDone').textContent = stats.done.toLocaleString('fa-IR');
document.getElementById('statSoon').textContent = stats.soon.toLocaleString('fa-IR');
document.getElementById('statOverdue').textContent = stats.overdue.toLocaleString('fa-IR');
document.getElementById('statProgress').textContent = stats.progress.toLocaleString('fa-IR') + '%';
}
};
// ============ Search/Filter Manager ============
const SearchManager = {
apply() {
AppState.search.query = document.getElementById('searchInput').value.trim().toLowerCase();
AppState.search.tag = document.getElementById('filterTag').value;
AppState.search.assignee = document.getElementById('filterAssignee').value;
AppState.search.status = document.getElementById('filterStatus').value;
this.applyVisualFilter();
this.updateStats();
},
matchesCard(card) {
const { query, tag, assignee, status } = AppState.search;
if (query) {
const title = (card.title || '').toLowerCase();
const desc = (card.description || '').toLowerCase();
if (!title.includes(query) && !desc.includes(query)) return false;
}
if (tag && !(card.tags || []).includes(tag)) return false;
if (assignee) {
const hasAssignee = (card.assignees || []).some(a =>
a.id === assignee || a.name === assignee
);
if (!hasAssignee) return false;
}
if (status) {
const cardStatus = this.getCardStatus(card);
if (cardStatus !== status) return false;
}
return true;
},
getCardStatus(card) {
if (!card.estimatedDate) return 'no-date';
const due = new Date(card.estimatedDate);
const now = new Date();
const diffDays = (due - now) / (1000 * 60 * 60 * 24);
if (diffDays < 0) return 'overdue';
if (diffDays <= 3) return 'soon';
return 'normal';
},
applyVisualFilter() {
const board = getActiveBoard();
if (!board) return;
const hasFilter = AppState.search.query || AppState.search.tag ||
AppState.search.assignee || AppState.search.status;
document.querySelectorAll('.card').forEach(cardEl => {
const cardId = cardEl.dataset.cardId;
const card = findCardById(cardId);
if (!card) return;
if (hasFilter && !this.matchesCard(card)) {
cardEl.classList.add('filtered-out');
} else {
cardEl.classList.remove('filtered-out');
}
});
document.querySelectorAll('.column').forEach(colEl => {
const colId = colEl.dataset.columnId;
const col = board.columns.find(c => c.id === colId);
if (!col) return;
if (hasFilter) {
const visibleCards = col.cards.filter(c => this.matchesCard(c));
if (visibleCards.length === 0) {
colEl.classList.add('filtered-out');
} else {
colEl.classList.remove('filtered-out');
}
} else {
colEl.classList.remove('filtered-out');
}
});
},
updateStats() {
const board = getActiveBoard();
if (!board) return;
let visibleCount = 0;
board.columns.forEach(col => {
col.cards.forEach(card => {
if (this.matchesCard(card)) visibleCount++;
});
});
const statsEl = document.getElementById('searchStats');
const hasFilter = AppState.search.query || AppState.search.tag ||
AppState.search.assignee || AppState.search.status;
if (hasFilter) {
statsEl.textContent = `${visibleCount.toLocaleString('fa-IR')} کارت یافت شد`;
} else {
const total = board.columns.reduce((sum, col) => sum + col.cards.length, 0);
statsEl.textContent = `${total.toLocaleString('fa-IR')} کارت`;
}
},
updateFilterOptions() {
const tagSelect = document.getElementById('filterTag');
const currentTag = tagSelect.value;
tagSelect.innerHTML = '<option value="">🏷️ همه تگ‌ها</option>';
AppState.tags.forEach(tag => {
const opt = document.createElement('option');
opt.value = tag.id;
opt.textContent = tag.name;
tagSelect.appendChild(opt);
});
tagSelect.value = currentTag;
const assigneeSelect = document.getElementById('filterAssignee');
const currentAssignee = assigneeSelect.value;
const assignees = new Map();
const board = getActiveBoard();
if (board) {
board.columns.forEach(col => {
col.cards.forEach(card => {
(card.assignees || []).forEach(a => {
assignees.set(a.name, a);
});
});
});
}
assigneeSelect.innerHTML = '<option value="">👥 همه مسئولین</option>';
assignees.forEach((a, name) => {
const opt = document.createElement('option');
opt.value = a.id;
opt.textContent = name;
assigneeSelect.appendChild(opt);
});
assigneeSelect.value = currentAssignee;
}
};
// ============ Export Manager ============
const ExportManager = {
toMarkdown(boardId) {
const boards = boardId === 'all'
? AppState.project.boards
: AppState.project.boards.filter(b => b.id === boardId);
let md = `# ${AppState.project.title}\n\n`;
md += `${AppState.project.description || ''}\n\n`;
md += `---\n\n`;
md += `*تاریخ ایجاد: ${formatDate(AppState.project.createdAt)}*\n\n`;
boards.forEach(board => {
md += `## 📋 ${board.title}\n\n`;
if (board.description) md += `${board.description}\n\n`;
board.columns.forEach(col => {
md += `### 📑 ${col.title} (${col.cards.length})\n\n`;
if (col.cards.length === 0) {
md += `_خالی_\n\n`;
} else {
col.cards.forEach(card => {
md += `#### ${card.title}\n\n`;
md += `${card.description}\n\n`;
if (card.estimatedDate) {
md += `**📅 تاریخ:** ${formatDate(card.estimatedDate)}\n\n`;
}
if (card.assignees && card.assignees.length > 0) {
md += `**👥 مسئولین:** ${card.assignees.map(a => a.name).join('، ')}\n\n`;
}
if (card.tags && card.tags.length > 0) {
const tagNames = card.tags.map(tid => {
const tag = AppState.tags.find(t => t.id === tid);
return tag ? tag.name : null;
}).filter(Boolean);
if (tagNames.length > 0) {
md += `**🏷️ برچسب‌ها:** ${tagNames.join('، ')}\n\n`;
}
}
if (card.checklist && card.checklist.length > 0) {
md += `**✅ چک‌لیست:**\n\n`;
card.checklist.forEach(item => {
md += `- [${item.done ? 'x' : ' '}] ${item.title}\n`;
});
md += `\n`;
}
md += `---\n\n`;
});
}
});
});
return md;
},
toCSV(boardId) {
const boards = boardId === 'all'
? AppState.project.boards
: AppState.project.boards.filter(b => b.id === boardId);
const headers = [
'برد', 'ستون', 'عنوان کارت', 'توضیحات',
'تاریخ تخمینی', 'مسئولین', 'برچسب‌ها',
'چک‌لیست (تکمیل/کل)', 'وابستگی‌ها', 'تاریخ ایجاد'
];
const rows = [headers];
boards.forEach(board => {
board.columns.forEach(col => {
col.cards.forEach(card => {
const assignees = (card.assignees || []).map(a => a.name).join(' | ');
const tags = (card.tags || []).map(tid => {
const tag = AppState.tags.find(t => t.id === tid);
return tag ? tag.name : '';
}).filter(Boolean).join(' | ');
const checklistTotal = (card.checklist || []).length;
const checklistDone = (card.checklist || []).filter(i => i.done).length;
const checklist = `${checklistDone}/${checklistTotal}`;
const deps = (card.dependencies || []).map(depId => {
const dep = findCardById(depId);
return dep ? dep.title : '';
}).filter(Boolean).join(' | ');
rows.push([
board.title,
col.title,
card.title,
(card.description || '').replace(/\n/g, ' '),
card.estimatedDate ? formatDate(card.estimatedDate) : '',
assignees,
tags,
checklist,
deps,
card.createdAt ? formatDate(card.createdAt) : ''
]);
});
});
});
return rows.map(row =>
row.map(cell => `"${String(cell).replace(/"/g, '""')}"`).join(',')
).join('\n');
},
download(content, filename, mimeType) {
const blob = new Blob([content], { type: mimeType + ';charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
};
// ============ Keyboard Navigation Manager ============
const KeyboardManager = {
focusedCardId: null,
handleKey(e) {
if (document.querySelector('.modal-overlay.active')) return;
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement.tagName)) {
if (e.key === 'Escape') document.activeElement.blur();
return;
}
const board = getActiveBoard();
if (!board) return;
const allCards = [];
board.columns.forEach(col => {
col.cards.forEach(card => allCards.push({ card, column: col }));
});
if (allCards.length === 0) return;
switch(e.key) {
case 'Tab':
e.preventDefault();
this.focusNextCard(allCards, e.shiftKey ? -1 : 1);
break;
case 'ArrowRight':
if (e.altKey && this.focusedCardId) {
e.preventDefault();
this.moveCardToAdjacentColumn(this.focusedCardId, -1);
}
break;
case 'ArrowLeft':
if (e.altKey && this.focusedCardId) {
e.preventDefault();
this.moveCardToAdjacentColumn(this.focusedCardId, 1);
}
break;
case 'Enter':
case ' ':
if (this.focusedCardId) {
e.preventDefault();
openEditCardModal(this.focusedCardId);
}
break;
case 'Escape':
this.clearFocus();
break;
}
},
focusNextCard(allCards, direction) {
let currentIdx = -1;
if (this.focusedCardId) {
currentIdx = allCards.findIndex(c => c.card.id === this.focusedCardId);
}
let nextIdx = currentIdx + direction;
if (nextIdx >= allCards.length) nextIdx = 0;
if (nextIdx < 0) nextIdx = allCards.length - 1;
this.clearFocus();
this.focusedCardId = allCards[nextIdx].card.id;
const cardEl = document.querySelector(`[data-card-id="${this.focusedCardId}"]`);
if (cardEl) {
cardEl.classList.add('keyboard-focused');
cardEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
},
moveCardToAdjacentColumn(cardId, direction) {
const board = getActiveBoard();
if (!board) return;
let currentColIdx = -1, cardIdx = -1, card = null;
for (let i = 0; i < board.columns.length; i++) {
const col = board.columns[i];
const idx = col.cards.findIndex(c => c.id === cardId);
if (idx >= 0) {
currentColIdx = i;
cardIdx = idx;
card = col.cards[idx];
break;
}
}
if (currentColIdx < 0) return;
const targetColIdx = currentColIdx + direction;
if (targetColIdx < 0 || targetColIdx >= board.columns.length) {
showToast('ستون دیگری در این جهت وجود ندارد', 'warning');
return;
}
const fromCol = board.columns[currentColIdx];
const toCol = board.columns[targetColIdx];
const cmd = new MoveCardCommand(
cardId, fromCol.id, toCol.id, cardIdx, toCol.cards.length
);
CommandHistory.execute(cmd);
renderBoard();
showToast(`کارت به "${toCol.title}" منتقل شد`, 'success');
},
clearFocus() {
if (this.focusedCardId) {
const el = document.querySelector(`[data-card-id="${this.focusedCardId}"]`);
if (el) el.classList.remove('keyboard-focused');
this.focusedCardId = null;
}
}
};
// ============ Theme Management ============
function toggleTheme() {
const current = document.documentElement.getAttribute('data-theme');
const newTheme = current === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', newTheme);
localStorage.setItem('xkanban-theme', newTheme);
updateThemeUI(newTheme);
}
function updateThemeUI(theme) {
const btn = document.getElementById('themeToggleBtn');
const icon = document.getElementById('themeIcon');
const text = document.getElementById('themeText');
if (theme === 'dark') {
btn.textContent = '☀️';
btn.title = 'تغییر به حالت روشن';
if (icon) icon.textContent = '☀️';
if (text) text.textContent = 'حالت روشن';
} else {
btn.textContent = '🌙';
btn.title = 'تغییر به حالت تاریک';
if (icon) icon.textContent = '🌙';
if (text) text.textContent = 'حالت تاریک';
}
}
function initTheme() {
const saved = localStorage.getItem('xkanban-theme') || 'dark';
document.documentElement.setAttribute('data-theme', saved);
updateThemeUI(saved);
}
// ============ Sidebar Management ============
function updateSidebarToggleIcon() {
const sidebar = document.getElementById('sidebar');
const btn = document.getElementById('sidebarToggleBtn');
if (!btn) return;
const isCollapsed = sidebar.classList.contains('collapsed');
if (isCollapsed) {
btn.textContent = '☰';
btn.title = 'باز کردن نوار ابزار';
} else {
btn.textContent = '✕';
btn.title = 'بستن نوار ابزار';
}
}
function toggleSidebar() {
const sidebar = document.getElementById('sidebar');
sidebar.classList.toggle('collapsed');
updateSidebarToggleIcon();
}
function toggleGroup(groupName) {
const group = document.querySelector(`.sidebar-group[data-group="${groupName}"]`);
if (group) {
group.classList.toggle('collapsed');
const header = group.querySelector('.sidebar-group-header');
if (header) {
header.setAttribute('aria-expanded', !group.classList.contains('collapsed'));
}
}
}
// ============ Modal Management ============
function openModal(id) { document.getElementById(id).classList.add('active'); }
function closeModal(id) { document.getElementById(id).classList.remove('active'); }
document.querySelectorAll('.modal-overlay').forEach(overlay => {
overlay.addEventListener('click', (e) => {
if (e.target === overlay) overlay.classList.remove('active');
});
});
// ============ Project Settings ============
function openProjectSettings() {
document.getElementById('projectTitle').value = AppState.project.title;
document.getElementById('projectDesc').value = AppState.project.description;
document.getElementById('projectCreatedAt').value = formatDate(AppState.project.createdAt);
// Load webhook settings
document.getElementById('webhookUrl').value = AppState.webhook.url;
document.getElementById('webhookEnabled').checked = AppState.webhook.enabled;
WebhookManager.updateStatusUI();
openModal('projectModal');
}
function saveProjectSettings() {
const title = document.getElementById('projectTitle').value.trim();
if (!title) { showToast('عنوان پروژه الزامی است', 'error'); return; }
const oldData = {
title: AppState.project.title,
description: AppState.project.description
};
const newData = {
title,
description: document.getElementById('projectDesc').value.trim()
};
// Save webhook settings
const webhookUrl = document.getElementById('webhookUrl').value.trim();
const webhookEnabled = document.getElementById('webhookEnabled').checked;
const webhookChanged = AppState.webhook.url !== webhookUrl ||
AppState.webhook.enabled !== webhookEnabled;
AppState.webhook.url = webhookUrl;
AppState.webhook.enabled = webhookEnabled;
WebhookManager.saveToStorage();
// Direct update for project settings
AppState.project.title = newData.title;
AppState.project.description = newData.description;
closeModal('projectModal');
showToast('تنظیمات پروژه ذخیره شد', 'success');
AutoSaveManager.scheduleSave();
// Send webhooks
WebhookManager.send(EVENT_TYPES.PROJECT_UPDATED, {
changes: Object.keys(newData),
webhookConfigChanged: webhookChanged,
webhookUrl: webhookUrl,
webhookEnabled: webhookEnabled
}, { triggeredBy: 'settings-save' });
}
async function testWebhook() {
const url = document.getElementById('webhookUrl').value.trim();
const enabled = document.getElementById('webhookEnabled').checked;
// Temporarily set for test
const prevUrl = AppState.webhook.url;
const prevEnabled = AppState.webhook.enabled;
AppState.webhook.url = url;
AppState.webhook.enabled = enabled;
const btn = document.getElementById('testWebhookBtn');
btn.disabled = true;
btn.textContent = '⏳ در حال تست...';
await WebhookManager.test();
btn.disabled = false;
btn.textContent = '🧪 تست';
// Restore if not saved yet
if (!document.getElementById('projectModal').classList.contains('active')) {
// Modal closed, keep current
}
}
// ============ Tag Management ============
function openTagManager() {
renderTagManageList();
openModal('tagModal');
}
function renderTagManageList() {
const list = document.getElementById('tagManageList');
if (AppState.tags.length === 0) {
list.innerHTML = '<div class="empty-state"><div class="empty-state-icon">🏷️</div><div>هنوز تگی ایجاد نشده</div></div>';
return;
}
list.innerHTML = AppState.tags.map(tag => `
<div class="tag-manage-item">
<div class="tag-color-preview" style="background:${tag.color};"></div>
<div class="tag-manage-name">${escapeHtml(tag.name)}</div>
<button class="btn btn-sm btn-danger" data-action="delete-tag" data-id="${tag.id}" aria-label="حذف تگ ${escapeHtml(tag.name)}">🗑️</button>
</div>
`).join('');
}
function addTag() {
const name = document.getElementById('newTagName').value.trim();
const color = document.getElementById('newTagColor').value;
if (!name) { showToast('نام تگ الزامی است', 'error'); return; }
if (AppState.tags.some(t => t.name === name)) {
showToast('تگی با این نام وجود دارد', 'warning'); return;
}
const cmd = new AddTagCommand({ name, color });
CommandHistory.execute(cmd);
document.getElementById('newTagName').value = '';
renderTagManageList();
SearchManager.updateFilterOptions();
showToast('تگ اضافه شد', 'success');
}
function deleteTag(id) {
if (!confirm('آیا از حذف این تگ مطمئن هستید؟')) return;
const cmd = new DeleteTagCommand(id);
CommandHistory.execute(cmd);
renderTagManageList();
renderBoard();
SearchManager.updateFilterOptions();
showToast('تگ حذف شد', 'success');
}
// ============ Color Picker ============
function renderColorPicker(containerId, colors, selected, callbackName) {
const container = document.getElementById(containerId);
container.innerHTML = colors.map(c => `
<div class="color-swatch ${c === selected ? 'selected' : ''}"
style="background:${c};${c === '#ffffff' ? 'border:1px solid var(--border);' : ''}"
data-action="select-color"
data-container="${containerId}"
data-color="${c}"
data-callback="${callbackName}"
role="button"
aria-label="انتخاب رنگ ${c}"
tabindex="0"></div>
`).join('');
}
function selectColor(containerId, color, callbackName) {
const container = document.getElementById(containerId);
container.querySelectorAll('.color-swatch').forEach(s => s.classList.remove('selected'));
const swatches = container.querySelectorAll('.color-swatch');
swatches.forEach(s => {
if (s.dataset.color === color) s.classList.add('selected');
});
if (window[callbackName]) window[callbackName](color);
}
function selectCustomColor(type, prop, color) {
if (type === 'board') {
if (prop === 'bg') AppState.tempData.boardBg = color;
} else if (type === 'column') {
if (prop === 'bg') AppState.tempData.columnBg = color;
else if (prop === 'text') AppState.tempData.columnText = color;
} else if (type === 'card') {
if (!AppState.tempData.card) AppState.tempData.card = {};
AppState.tempData.card.backgroundColor = color;
}
}
// ============ Board Management ============
function openNewBoardModal() {
AppState.editing.boardId = null;
document.getElementById('boardModalTitle').textContent = 'ایجاد برد جدید';
document.getElementById('boardTitle').value = '';
document.getElementById('boardDesc').value = '';
document.getElementById('boardTitleError').style.display = 'none';
const defaultBg = document.documentElement.getAttribute('data-theme') === 'dark' ? '#2d3748' : '#e2e8f0';
AppState.tempData.boardBg = defaultBg;
renderColorPicker('boardBgColors', COLOR_PALETTE, AppState.tempData.boardBg, 'setBoardBg');
document.getElementById('boardBgCustom').value = defaultBg;
openModal('boardModal');
document.getElementById('boardTitle').focus();
}
function setBoardBg(c) { AppState.tempData.boardBg = c; document.getElementById('boardBgCustom').value = c; }
function editCurrentBoard() {
if (!AppState.activeBoardId) { showToast('بردی برای ویرایش وجود ندارد', 'warning'); return; }
openEditBoardModal(AppState.activeBoardId);
}
function openEditBoardModal(boardId) {
const board = AppState.project.boards.find(b => b.id === boardId);
if (!board) return;
AppState.editing.boardId = boardId;
document.getElementById('boardModalTitle').textContent = 'ویرایش برد';
document.getElementById('boardTitle').value = board.title;
document.getElementById('boardDesc').value = board.description;
document.getElementById('boardTitleError').style.display = 'none';
const defaultBg = document.documentElement.getAttribute('data-theme') === 'dark' ? '#2d3748' : '#e2e8f0';
AppState.tempData.boardBg = board.backgroundColor || defaultBg;
renderColorPicker('boardBgColors', COLOR_PALETTE, AppState.tempData.boardBg, 'setBoardBg');
document.getElementById('boardBgCustom').value = AppState.tempData.boardBg;
openModal('boardModal');
}
function validateBoardTitle(title, excludeId = null) {
if (!title.trim()) return 'عنوان برد الزامی است';
const exists = AppState.project.boards.some(b =>
b.title.trim().toLowerCase() === title.trim().toLowerCase() &&
b.id !== excludeId
);
if (exists) return 'بردی با این عنوان وجود دارد';
return null;
}
function saveBoard() {
const title = document.getElementById('boardTitle').value.trim();
const error = validateBoardTitle(title, AppState.editing.boardId);
if (error) {
const errorEl = document.getElementById('boardTitleError');
errorEl.textContent = error;
errorEl.style.display = 'block';
return;
}
const desc = document.getElementById('boardDesc').value.trim();
if (AppState.editing.boardId) {
const board = AppState.project.boards.find(b => b.id === AppState.editing.boardId);
const oldData = {
title: board.title,
description: board.description,
backgroundColor: board.backgroundColor
};
const newData = {
title,
description: desc,
backgroundColor: AppState.tempData.boardBg
};
const cmd = new UpdateBoardCommand(AppState.editing.boardId, oldData, newData);
CommandHistory.execute(cmd);
showToast('برد بروزرسانی شد', 'success');
} else {
const cmd = new CreateBoardCommand({
title,
description: desc,
backgroundColor: AppState.tempData.boardBg
});
CommandHistory.execute(cmd);
showToast('برد ایجاد شد', 'success');
}
closeModal('boardModal');
renderTabs();
renderBoard();
}
function deleteCurrentBoard() {
if (!AppState.activeBoardId) { showToast('بردی برای حذف وجود ندارد', 'warning'); return; }
deleteBoard(AppState.activeBoardId);
}
function deleteBoard(boardId) {
const board = AppState.project.boards.find(b => b.id === boardId);
if (!board) return;
if (!confirm(`آیا از حذف برد "${board.title}" و تمام محتویات آن مطمئن هستید؟\nاین عمل قابل بازگشت نیست.`)) return;
const cmd = new DeleteBoardCommand(boardId);
CommandHistory.execute(cmd);
renderTabs();
renderBoard();
showToast('برد حذف شد', 'success');
}
function switchBoard(boardId) {
AppState.activeBoardId = boardId;
KeyboardManager.clearFocus();
renderTabs();
renderBoard();
}
// ============ Tabs Rendering ============
function renderTabs() {
const tabsList = document.getElementById('tabsList');
if (AppState.project.boards.length === 0) {
tabsList.innerHTML = '<div style="color:rgba(255,255,255,0.5);font-size:12px;padding:0 10px;">هیچ بردی ایجاد نشده است</div>';
return;
}
tabsList.innerHTML = AppState.project.boards.map(board => `
<button class="tab-item ${board.id === AppState.activeBoardId ? 'active' : ''}"
data-action="switch-board"
data-id="${board.id}"
role="tab"
aria-selected="${board.id === AppState.activeBoardId}">
<span>📋</span>
<span>${escapeHtml(board.title)}</span>
<button class="tab-close" data-action="delete-board" data-id="${board.id}" title="حذف برد" aria-label="حذف برد ${escapeHtml(board.title)}">✕</button>
</button>
`).join('');
}
// ============ Board Rendering ============
function renderBoard() {
const empty = document.getElementById('boardEmpty');
const wrapper = document.getElementById('boardWrapper');
const boardArea = document.getElementById('boardArea');
const board = AppState.project.boards.find(b => b.id === AppState.activeBoardId);
if (!board) {
empty.style.display = 'flex';
wrapper.style.display = 'none';
boardArea.style.background = '';
StatsManager.update();
SearchManager.updateStats();
return;
}
empty.style.display = 'none';
wrapper.style.display = 'flex';
const boardBg = board.backgroundColor || '';
boardArea.style.background = boardBg || '';
wrapper.innerHTML = board.columns.map(col => renderColumn(col, board)).join('') + `
<button class="add-column-btn" data-action="new-column" aria-label="افزودن ستون جدید">
➕ افزودن ستون جدید
</button>
`;
attachColumnDragEvents();
attachCardDragEvents();
StatsManager.update();
SearchManager.updateFilterOptions();
SearchManager.apply();
}
function renderColumn(col, board) {
const cardCount = col.cards.length;
const colBg = col.backgroundColor || '';
const colText = col.textColor || '';
const colStyle = [];
if (colBg) colStyle.push(`background:${colBg};`);
if (colText) colStyle.push(`color:${colText};`);
const styleStr = colStyle.join('');
return `
<div class="column" data-column-id="${col.id}" draggable="false" style="${styleStr}">
<div class="column-header" draggable="true" data-col-drag="${col.id}">
<div class="column-title">
<span>${escapeHtml(col.title)}</span>
<span class="column-count">${cardCount}</span>
</div>
<div class="column-actions">
<button class="column-action-btn" data-action="edit-column" data-id="${col.id}" title="ویرایش" aria-label="ویرایش ستون">✏️</button>
<button class="column-action-btn" data-action="delete-column" data-id="${col.id}" title="حذف" aria-label="حذف ستون">🗑️</button>
</div>
</div>
<div class="column-body" data-column-id="${col.id}">
${col.cards.length === 0 ? `
<div class="empty-state" style="padding:20px 10px;">
<div style="font-size:30px;opacity:0.3;">📭</div>
<div style="font-size:11px;margin-top:6px;">کارتی وجود ندارد</div>
</div>
` : col.cards.map(card => renderCard(card, col)).join('')}
</div>
<div class="column-footer">
<button class="add-card-btn" data-action="new-card" data-column-id="${col.id}" aria-label="افزودن کارت">➕ افزودن کارت</button>
</div>
</div>
`;
}
function renderCard(card, column) {
const tagHtml = (card.tags || []).map(tid => {
const tag = AppState.tags.find(t => t.id === tid);
if (!tag) return '';
return `<span class="card-tag" style="background:${tag.color};">${escapeHtml(tag.name)}</span>`;
}).join('');
const assigneesHtml = (card.assignees || []).map(a => {
const initial = (a.name || '?').charAt(0).toUpperCase();
return `<div class="assignee-avatar" title="${escapeHtml(a.name)}">${escapeHtml(initial)}</div>`;
}).join('');
const checklistTotal = (card.checklist || []).length;
const checklistDone = (card.checklist || []).filter(i => i.done).length;
const checklistHtml = checklistTotal > 0 ? `
<div class="card-checklist-progress" title="${checklistDone}/${checklistTotal}">
<div class="card-checklist-bar" style="width:${(checklistDone/checklistTotal)*100}%;"></div>
</div>
` : '';
const depCount = (card.dependencies || []).length;
const depHtml = depCount > 0 ? `<div class="card-dep-indicator" title="${depCount} وابستگی">🔗 ${depCount}</div>` : '';
let dateClass = '';
let dateText = '';
if (card.estimatedDate) {
const due = new Date(card.estimatedDate);
const now = new Date();
const diff = (due - now) / (1000*60*60*24);
dateText = formatDateShort(card.estimatedDate);
if (diff < 0) dateClass = 'overdue';
else if (diff <= 3) dateClass = 'soon';
}
const bgStyle = card.backgroundColor ? `background:${card.backgroundColor};` : '';
const textStyle = card.textColor ? `color:${card.textColor};` : '';
return `
<div class="card" draggable="true" data-card-id="${card.id}" style="${bgStyle}${textStyle}"
data-action="edit-card" data-id="${card.id}"
role="button" tabindex="0"
aria-label="${escapeHtml(card.title)}">
${depHtml}
${tagHtml ? `<div class="card-tags">${tagHtml}</div>` : ''}
<div class="card-title">${escapeHtml(card.title)}</div>
${card.description ? `<div class="card-desc">${escapeHtml(card.description)}</div>` : ''}
<div class="card-meta">
${card.estimatedDate ? `<span class="card-meta-item ${dateClass}">📅 ${dateText}</span>` : ''}
${checklistTotal > 0 ? `<span class="card-meta-item">✅ ${checklistDone}/${checklistTotal}</span>` : ''}
${(card.assignees || []).length > 0 ? `<div class="card-assignees">${assigneesHtml}</div>` : ''}
</div>
${checklistHtml}
</div>
`;
}
// ============ Column Management ============
function openNewColumnModal() {
if (!AppState.activeBoardId) { showToast('ابتدا یک برد ایجاد کنید', 'warning'); return; }
AppState.editing.columnId = null;
document.getElementById('columnModalTitle').textContent = 'ایجاد ستون جدید';
document.getElementById('columnTitle').value = '';
document.getElementById('columnTitleError').style.display = 'none';
const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
AppState.tempData.columnBg = isDark ? '#374151' : '#edf2f7';
AppState.tempData.columnText = isDark ? '#e2e8f0' : '#1a202c';
renderColorPicker('columnBgColors', COLOR_PALETTE, AppState.tempData.columnBg, 'setColumnBg');
renderColorPicker('columnTextColors', TEXT_COLORS, AppState.tempData.columnText, 'setColumnText');
document.getElementById('columnBgCustom').value = AppState.tempData.columnBg;
document.getElementById('columnTextCustom').value = AppState.tempData.columnText;
openModal('columnModal');
document.getElementById('columnTitle').focus();
}
function setColumnBg(c) { AppState.tempData.columnBg = c; document.getElementById('columnBgCustom').value = c; }
function setColumnText(c) { AppState.tempData.columnText = c; document.getElementById('columnTextCustom').value = c; }
function openEditColumnModal(colId) {
const board = getActiveBoard();
const col = board.columns.find(c => c.id === colId);
if (!col) return;
AppState.editing.columnId = colId;
document.getElementById('columnModalTitle').textContent = 'ویرایش ستون';
document.getElementById('columnTitle').value = col.title;
document.getElementById('columnTitleError').style.display = 'none';
const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
AppState.tempData.columnBg = col.backgroundColor || (isDark ? '#374151' : '#edf2f7');
AppState.tempData.columnText = col.textColor || (isDark ? '#e2e8f0' : '#1a202c');
renderColorPicker('columnBgColors', COLOR_PALETTE, AppState.tempData.columnBg, 'setColumnBg');
renderColorPicker('columnTextColors', TEXT_COLORS, AppState.tempData.columnText, 'setColumnText');
document.getElementById('columnBgCustom').value = AppState.tempData.columnBg;
document.getElementById('columnTextCustom').value = AppState.tempData.columnText;
openModal('columnModal');
}
function validateColumnTitle(title, excludeId = null) {
if (!title.trim()) return 'عنوان ستون الزامی است';
const board = getActiveBoard();
if (!board) return null;
const exists = board.columns.some(c =>
c.title.trim().toLowerCase() === title.trim().toLowerCase() &&
c.id !== excludeId
);
if (exists) return 'ستونی با این عنوان وجود دارد';
return null;
}
function saveColumn() {
const title = document.getElementById('columnTitle').value.trim();
const error = validateColumnTitle(title, AppState.editing.columnId);
if (error) {
const errorEl = document.getElementById('columnTitleError');
errorEl.textContent = error;
errorEl.style.display = 'block';
return;
}
const board = getActiveBoard();
if (!board) return;
if (AppState.editing.columnId) {
const col = board.columns.find(c => c.id === AppState.editing.columnId);
const oldData = {
title: col.title,
backgroundColor: col.backgroundColor,
textColor: col.textColor
};
const newData = {
title,
backgroundColor: AppState.tempData.columnBg,
textColor: AppState.tempData.columnText
};
const cmd = new UpdateColumnCommand(board.id, AppState.editing.columnId, oldData, newData);
CommandHistory.execute(cmd);
showToast('ستون بروزرسانی شد', 'success');
} else {
const cmd = new CreateColumnCommand(board.id, {
title,
backgroundColor: AppState.tempData.columnBg,
textColor: AppState.tempData.columnText
});
CommandHistory.execute(cmd);
showToast('ستون ایجاد شد', 'success');
}
closeModal('columnModal');
renderBoard();
}
function deleteColumn(colId) {
if (!confirm('آیا از حذف این ستون و تمام کارت‌های آن مطمئن هستید؟')) return;
const board = getActiveBoard();
if (!board) return;
const cmd = new DeleteColumnCommand(board.id, colId);
CommandHistory.execute(cmd);
renderBoard();
showToast('ستون حذف شد', 'success');
}
// ============ Card Management ============
function openNewCardModal(columnId) {
AppState.editing.cardId = null;
AppState.editing.columnId = columnId;
document.getElementById('cardModalTitle').textContent = 'ایجاد کارت جدید';
document.getElementById('cardTitle').value = '';
document.getElementById('cardDesc').value = '';
document.getElementById('cardDate').value = '';
document.getElementById('deleteCardBtn').style.display = 'none';
AppState.tempData.card = {
assignees: [],
tags: [],
dependencies: [],
checklist: [],
history: [{ from: '—', to: findColumnById(columnId)?.title || '—', date: new Date().toISOString() }],
backgroundColor: null,
textColor: null
};
renderColorPicker('cardBgColors', COLOR_PALETTE, null, 'setCardBg');
document.getElementById('cardBgCustom').value = '#ffffff';
renderCardEditor();
openModal('cardModal');
document.getElementById('cardTitle').focus();
}
function setCardBg(c) {
if (!AppState.tempData.card) AppState.tempData.card = {};
AppState.tempData.card.backgroundColor = c;
document.getElementById('cardBgCustom').value = c;
}
function openEditCardModal(cardId) {
const { card, column } = findCard(cardId);
if (!card) return;
AppState.editing.cardId = cardId;
AppState.editing.columnId = column.id;
document.getElementById('cardModalTitle').textContent = 'ویرایش کارت';
document.getElementById('cardTitle').value = card.title;
document.getElementById('cardDesc').value = card.description || '';
if (card.estimatedDate) {
const d = new Date(card.estimatedDate);
const local = new Date(d.getTime() - d.getTimezoneOffset()*60000).toISOString().slice(0,16);
document.getElementById('cardDate').value = local;
} else {
document.getElementById('cardDate').value = '';
}
document.getElementById('deleteCardBtn').style.display = 'inline-flex';
AppState.tempData.card = {
assignees: [...(card.assignees || [])],
tags: [...(card.tags || [])],
dependencies: [...(card.dependencies || [])],
checklist: (card.checklist || []).map(i => ({...i})),
history: [...(card.history || [])],
backgroundColor: card.backgroundColor || null,
textColor: card.textColor || null
};
renderColorPicker('cardBgColors', COLOR_PALETTE, AppState.tempData.card.backgroundColor, 'setCardBg');
document.getElementById('cardBgCustom').value = AppState.tempData.card.backgroundColor || '#ffffff';
renderCardEditor();
openModal('cardModal');
}
function renderCardEditor() {
renderAssignees();
renderCardTags();
renderDependencies();
renderChecklist();
renderHistory();
renderDependencySelect();
}
function renderAssignees() {
const container = document.getElementById('assigneesContainer');
const list = AppState.tempData.card.assignees;
if (list.length === 0) {
container.innerHTML = '<div class="empty-state" style="padding:10px;"><div style="font-size:12px;">هنوز کسی اضافه نشده</div></div>';
return;
}
container.innerHTML = list.map((a, idx) => `
<div class="assignee-item">
<div class="assignee-avatar-lg">${escapeHtml((a.name||'?').charAt(0).toUpperCase())}</div>
<div class="assignee-name">${escapeHtml(a.name)}</div>
<button class="btn btn-sm btn-danger" data-action="remove-assignee" data-idx="${idx}" aria-label="حذف ${escapeHtml(a.name)}">✕</button>
</div>
`).join('');
}
function addAssignee() {
const input = document.getElementById('newAssigneeInput');
const name = input.value.trim();
if (!name) return;
AppState.tempData.card.assignees.push({ id: generateId(), name });
input.value = '';
renderAssignees();
}
function removeAssignee(idx) {
AppState.tempData.card.assignees.splice(idx, 1);
renderAssignees();
}
function renderCardTags() {
const container = document.getElementById('cardTagsContainer');
if (AppState.tags.length === 0) {
container.innerHTML = '<div class="empty-state" style="padding:10px;width:100%;"><div style="font-size:12px;">تگی وجود ندارد. از منوی اصلی تگ ایجاد کنید.</div></div>';
return;
}
container.innerHTML = AppState.tags.map(tag => {
const selected = AppState.tempData.card.tags.includes(tag.id);
return `<div class="tag-chip ${selected ? 'selected' : ''}" style="background:${tag.color};" data-action="toggle-tag" data-id="${tag.id}" role="button" tabindex="0" aria-pressed="${selected}">${escapeHtml(tag.name)}</div>`;
}).join('');
}
function toggleCardTag(tagId) {
const idx = AppState.tempData.card.tags.indexOf(tagId);
if (idx >= 0) AppState.tempData.card.tags.splice(idx, 1);
else AppState.tempData.card.tags.push(tagId);
renderCardTags();
}
function renderDependencySelect() {
const select = document.getElementById('depSelect');
const board = getActiveBoard();
if (!board) { select.innerHTML = '<option>بردی وجود ندارد</option>'; select.disabled = true; return; }
const allCards = [];
board.columns.forEach(col => {
col.cards.forEach(card => {
if (card.id !== AppState.editing.cardId) {
allCards.push({ id: card.id, title: card.title, columnTitle: col.title });
}
});
});
if (allCards.length === 0) {
select.innerHTML = '<option>کارت دیگری وجود ندارد</option>';
select.disabled = true;
return;
}
select.disabled = false;
select.innerHTML = '<option value="">-- انتخاب کارت --</option>' +
allCards.map(c => `<option value="${c.id}">${escapeHtml(c.title)} (${escapeHtml(c.columnTitle)})</option>`).join('');
}
function renderDependencies() {
const container = document.getElementById('depList');
const deps = AppState.tempData.card.dependencies;
if (deps.length === 0) {
container.innerHTML = '<div class="empty-state" style="padding:10px;"><div style="font-size:12px;">وابستگی انتخاب نشده</div></div>';
return;
}
container.innerHTML = deps.map(depId => {
const depCard = findCardById(depId);
if (!depCard) return '';
return `
<div class="dep-item">
<div class="dep-status"></div>
<div class="dep-title">${escapeHtml(depCard.title)}</div>
<button class="btn btn-sm btn-danger" data-action="remove-dependency" data-id="${depId}" aria-label="حذف وابستگی">✕</button>
</div>
`;
}).join('');
}
function addDependency() {
const select = document.getElementById('depSelect');
const depId = select.value;
if (!depId) { showToast('یک کارت انتخاب کنید', 'warning'); return; }
if (AppState.tempData.card.dependencies.includes(depId)) {
showToast('این وابستگی قبلاً اضافه شده', 'warning'); return;
}
AppState.tempData.card.dependencies.push(depId);
renderDependencies();
}
function removeDependency(depId) {
AppState.tempData.card.dependencies = AppState.tempData.card.dependencies.filter(d => d !== depId);
renderDependencies();
}
function renderChecklist() {
const container = document.getElementById('checklistContainer');
const list = AppState.tempData.card.checklist;
if (list.length === 0) {
container.innerHTML = '<div class="empty-state" style="padding:10px;"><div style="font-size:12px;">آیتمی وجود ندارد</div></div>';
return;
}
container.innerHTML = list.map((item, idx) => `
<div class="checklist-item ${item.done ? 'done' : ''}">
<input type="checkbox" ${item.done ? 'checked' : ''} data-action="toggle-checklist" data-idx="${idx}" aria-label="${escapeHtml(item.title)}">
<div class="checklist-text">${escapeHtml(item.title)}</div>
<button class="checklist-remove" data-action="remove-checklist" data-idx="${idx}" aria-label="حذف">✕</button>
</div>
`).join('');
}
function addChecklistItem() {
const input = document.getElementById('newChecklistInput');
const title = input.value.trim();
if (!title) return;
AppState.tempData.card.checklist.push({ id: generateId(), title, done: false });
input.value = '';
renderChecklist();
}
function toggleChecklistItem(idx) {
AppState.tempData.card.checklist[idx].done = !AppState.tempData.card.checklist[idx].done;
renderChecklist();
}
function removeChecklistItem(idx) {
AppState.tempData.card.checklist.splice(idx, 1);
renderChecklist();
}
function renderHistory() {
const container = document.getElementById('historyList');
const history = AppState.tempData.card.history;
if (history.length === 0) {
container.innerHTML = '<div class="empty-state" style="padding:10px;"><div style="font-size:12px;">هنوز تاریخچه‌ای ثبت نشده</div></div>';
return;
}
container.innerHTML = history.slice().reverse().map(h => `
<div class="history-item">
<div class="history-icon">${h.action === 'reorder' ? '↕' : '→'}</div>
<div class="history-content">
<div>از <strong>${escapeHtml(h.from || '—')}</strong> به <strong>${escapeHtml(h.to || '—')}</strong>${h.action === 'reorder' ? ' <em>(تغییر ترتیب)</em>' : ''}</div>
<div class="history-date">${formatDate(h.date)}</div>
</div>
</div>
`).join('');
}
function saveCard() {
const title = document.getElementById('cardTitle').value.trim();
const desc = document.getElementById('cardDesc').value.trim();
const dateVal = document.getElementById('cardDate').value;
if (!title) { showToast('عنوان کارت الزامی است', 'error'); return; }
if (!desc) { showToast('توضیحات الزامی است', 'error'); return; }
const board = getActiveBoard();
if (AppState.editing.cardId) {
const { card } = findCard(AppState.editing.cardId);
const oldData = {
title: card.title,
description: card.description,
estimatedDate: card.estimatedDate,
assignees: card.assignees,
tags: card.tags,
dependencies: card.dependencies,
checklist: card.checklist,
backgroundColor: card.backgroundColor,
textColor: card.textColor
};
const newData = {
title,
description: desc,
estimatedDate: dateVal ? new Date(dateVal).toISOString() : null,
assignees: AppState.tempData.card.assignees,
tags: AppState.tempData.card.tags,
dependencies: AppState.tempData.card.dependencies,
checklist: AppState.tempData.card.checklist,
backgroundColor: AppState.tempData.card.backgroundColor,
textColor: AppState.tempData.card.textColor
};
const cmd = new UpdateCardCommand(AppState.editing.cardId, oldData, newData);
CommandHistory.execute(cmd);
showToast('کارت بروزرسانی شد', 'success');
} else {
const cmd = new CreateCardCommand(AppState.editing.columnId, {
title,
description: desc,
estimatedDate: dateVal ? new Date(dateVal).toISOString() : null,
assignees: AppState.tempData.card.assignees,
tags: AppState.tempData.card.tags,
dependencies: AppState.tempData.card.dependencies,
checklist: AppState.tempData.card.checklist,
history: AppState.tempData.card.history,
backgroundColor: AppState.tempData.card.backgroundColor,
textColor: AppState.tempData.card.textColor
});
CommandHistory.execute(cmd);
showToast('کارت ایجاد شد', 'success');
}
closeModal('cardModal');
renderBoard();
}
function deleteCard() {
if (!confirm('آیا از حذف این کارت مطمئن هستید؟')) return;
const cmd = new DeleteCardCommand(AppState.editing.cardId);
CommandHistory.execute(cmd);
closeModal('cardModal');
renderBoard();
showToast('کارت حذف شد', 'success');
}
// ============ Helpers ============
function getActiveBoard() {
return AppState.project.boards.find(b => b.id === AppState.activeBoardId);
}
function findColumnById(colId) {
const board = getActiveBoard();
if (!board) return null;
return board.columns.find(c => c.id === colId);
}
function findCard(cardId) {
const board = getActiveBoard();
if (!board) return { card: null, column: null, board: null };
for (const col of board.columns) {
const card = col.cards.find(c => c.id === cardId);
if (card) return { card, column: col, board };
}
return { card: null, column: null, board };
}
function findCardById(cardId) {
const { card } = findCard(cardId);
return card;
}
function findCardContext(cardId, columnId) {
const board = getActiveBoard();
if (!board) return { card: null, column: null, board: null };
if (columnId) {
const column = board.columns.find(c => c.id === columnId);
if (column) {
const card = cardId ? column.cards.find(c => c.id === cardId) : null;
return { card, column, board };
}
}
if (cardId) {
for (const col of board.columns) {
const card = col.cards.find(c => c.id === cardId);
if (card) return { card, column: col, board };
}
}
return { card: null, column: null, board: null };
}
// ============ Drag & Drop: Columns ============
let draggedColumnId = null;
function attachColumnDragEvents() {
document.querySelectorAll('[data-col-drag]').forEach(header => {
const colId = header.getAttribute('data-col-drag');
const column = header.closest('.column');
header.addEventListener('dragstart', (e) => {
draggedColumnId = colId;
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', 'column');
setTimeout(() => column.classList.add('dragging-col'), 0);
});
header.addEventListener('dragend', () => {
column.classList.remove('dragging-col');
draggedColumnId = null;
document.querySelectorAll('.column').forEach(c => c.classList.remove('drag-over'));
});
});
document.querySelectorAll('.column').forEach(col => {
col.addEventListener('dragover', (e) => {
if (!draggedColumnId || draggedColumnId === col.dataset.columnId) return;
e.preventDefault();
col.classList.add('drag-over');
});
col.addEventListener('dragleave', (e) => {
if (!col.contains(e.relatedTarget)) col.classList.remove('drag-over');
});
col.addEventListener('drop', (e) => {
e.preventDefault();
col.classList.remove('drag-over');
if (!draggedColumnId || draggedColumnId === col.dataset.columnId) return;
const board = getActiveBoard();
const fromIdx = board.columns.findIndex(c => c.id === draggedColumnId);
const toIdx = board.columns.findIndex(c => c.id === col.dataset.columnId);
if (fromIdx < 0 || toIdx < 0) return;
const cmd = new ReorderColumnsCommand(board.id, fromIdx, toIdx);
CommandHistory.execute(cmd);
draggedColumnId = null;
renderBoard();
showToast('ترتیب ستون‌ها تغییر کرد', 'success');
});
});
}
// ============ Drag & Drop: Cards ============
let draggedCardId = null;
let draggedFromColumnId = null;
let draggedFromIndex = -1;
let isDragging = false;
function attachCardDragEvents() {
document.querySelectorAll('.card').forEach(cardEl => {
cardEl.addEventListener('dragstart', (e) => {
isDragging = true;
draggedCardId = cardEl.dataset.cardId;
const colBody = cardEl.closest('.column-body');
draggedFromColumnId = colBody.dataset.columnId;
const col = findColumnById(draggedFromColumnId);
draggedFromIndex = col ? col.cards.findIndex(c => c.id === draggedCardId) : -1;
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', 'card');
e.stopPropagation();
setTimeout(() => cardEl.classList.add('dragging'), 0);
});
cardEl.addEventListener('dragend', () => {
cardEl.classList.remove('dragging');
setTimeout(() => { isDragging = false; }, 100);
draggedCardId = null;
draggedFromColumnId = null;
draggedFromIndex = -1;
document.querySelectorAll('.column-body').forEach(b => b.classList.remove('drag-target'));
});
});
document.querySelectorAll('.column-body').forEach(body => {
body.addEventListener('dragover', (e) => {
if (!draggedCardId) return;
e.preventDefault();
body.classList.add('drag-target');
});
body.addEventListener('dragleave', (e) => {
if (e.target === body) body.classList.remove('drag-target');
});
body.addEventListener('drop', (e) => {
e.preventDefault();
body.classList.remove('drag-target');
if (!draggedCardId || isDragging === false) return;
const targetColumnId = body.dataset.columnId;
const board = getActiveBoard();
if (!board) return;
const afterElement = getDragAfterElement(body, e.clientY);
const toCol = board.columns.find(c => c.id === targetColumnId);
let toIndex;
if (afterElement == null) {
toIndex = toCol.cards.length;
} else {
toIndex = toCol.cards.findIndex(c => c.id === afterElement.dataset.cardId);
}
if (draggedFromColumnId === targetColumnId && draggedFromIndex < toIndex) {
toIndex--;
}
if (toIndex < 0) toIndex = 0;
if (draggedFromColumnId === targetColumnId && draggedFromIndex === toIndex) {
return;
}
const cmd = new MoveCardCommand(
draggedCardId,
draggedFromColumnId,
targetColumnId,
draggedFromIndex,
toIndex
);
CommandHistory.execute(cmd);
const toColName = toCol.title;
draggedCardId = null;
draggedFromColumnId = null;
draggedFromIndex = -1;
renderBoard();
showToast(`کارت به "${toColName}" منتقل شد`, 'success');
});
});
}
function getDragAfterElement(container, y) {
const cards = [...container.querySelectorAll('.card:not(.dragging)')];
return cards.reduce((closest, child) => {
const box = child.getBoundingClientRect();
const offset = y - box.top - box.height / 2;
if (offset < 0 && offset > closest.offset) {
return { offset, element: child };
}
return closest;
}, { offset: Number.NEGATIVE_INFINITY }).element;
}
// ============ Event Delegation ============
document.addEventListener('click', (e) => {
const target = e.target.closest('[data-action]');
if (!target) return;
const action = target.dataset.action;
const id = target.dataset.id;
if (action === 'delete-board' && target.classList.contains('tab-close')) {
e.stopPropagation();
deleteBoard(id);
return;
}
switch (action) {
case 'switch-board':
if (!e.target.closest('.tab-close')) switchBoard(id);
break;
case 'delete-board': deleteBoard(id); break;
case 'new-column': openNewColumnModal(); break;
case 'edit-column': openEditColumnModal(id); break;
case 'delete-column': deleteColumn(id); break;
case 'new-card': openNewCardModal(target.dataset.columnId); break;
case 'edit-card':
if (!isDragging) openEditCardModal(id);
break;
case 'select-color':
selectColor(target.dataset.container, target.dataset.color, target.dataset.callback);
break;
case 'toggle-tag': toggleCardTag(id); break;
case 'remove-assignee': removeAssignee(parseInt(target.dataset.idx)); break;
case 'remove-dependency': removeDependency(id); break;
case 'remove-checklist': removeChecklistItem(parseInt(target.dataset.idx)); break;
case 'delete-tag': deleteTag(id); break;
}
});
document.addEventListener('change', (e) => {
const target = e.target;
if (target.matches('[data-action="toggle-checklist"]')) {
toggleChecklistItem(parseInt(target.dataset.idx));
}
});
// ============ Save / Load ============
function saveProject() {
document.getElementById('saveFileName').value = `${AppState.project.title.replace(/[^\w\u0600-\u06FF]/g, '_') || 'xkanban'}.json`;
openModal('saveModal');
}
function downloadProject() {
const fileName = document.getElementById('saveFileName').value.trim() || 'xkanban-project.json';
const data = {
version: APP_VERSION,
project: AppState.project,
tags: AppState.tags,
activeBoardId: AppState.activeBoardId,
exportedAt: new Date().toISOString(),
author: 'Hadi Khazaee Asl'
};
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = fileName;
a.click();
URL.revokeObjectURL(url);
closeModal('saveModal');
showToast('پروژه با موفقیت ذخیره شد', 'success');
WebhookManager.send(EVENT_TYPES.PROJECT_SAVED, {
fileName: fileName,
boardsCount: AppState.project.boards.length,
tagsCount: AppState.tags.length
}, { triggeredBy: 'manual-save' });
}
function loadProject() {
document.getElementById('fileInput').click();
}
document.getElementById('fileInput').addEventListener('change', (e) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = async (evt) => {
try {
let data = JSON.parse(evt.target.result);
if (!data.project || !Array.isArray(data.project.boards)) {
throw new Error('ساختار فایل نامعتبر است');
}
data = MigrationManager.migrate(data);
AppState.project = data.project;
AppState.tags = data.tags || [];
AppState.activeBoardId = data.activeBoardId || AppState.project.boards[0]?.id || null;
CommandHistory.clear();
renderTabs();
renderBoard();
showToast('پروژه با موفقیت بارگذاری شد', 'success');
AutoSaveManager.scheduleSave();
await WebhookManager.send(EVENT_TYPES.PROJECT_LOADED, {
fileName: file.name,
fileSize: file.size,
boardsCount: AppState.project.boards.length,
tagsCount: AppState.tags.length
}, { triggeredBy: 'file-load' });
} catch (err) {
showToast('خطا در بارگذاری فایل: ' + err.message, 'error');
}
};
reader.readAsText(file);
e.target.value = '';
});
// ============ Export ============
function openExportModal() {
const select = document.getElementById('exportBoardSelect');
select.innerHTML = '<option value="all">همه بردها</option>';
AppState.project.boards.forEach(board => {
const opt = document.createElement('option');
opt.value = board.id;
opt.textContent = board.title;
select.appendChild(opt);
});
openModal('exportModal');
}
function exportProject() {
const boardId = document.getElementById('exportBoardSelect').value;
const format = document.getElementById('exportFormat').value;
const boardName = boardId === 'all'
? AppState.project.title
: AppState.project.boards.find(b => b.id === boardId)?.title || 'board';
const safeName = boardName.replace(/[^\w\u0600-\u06FF]/g, '_');
if (format === 'markdown') {
const content = ExportManager.toMarkdown(boardId);
ExportManager.download(content, `${safeName}.md`, 'text/markdown');
} else if (format === 'csv') {
const content = '\uFEFF' + ExportManager.toCSV(boardId);
ExportManager.download(content, `${safeName}.csv`, 'text/csv');
}
closeModal('exportModal');
showToast('خروجی با موفقیت ایجاد شد', 'success');
}
// ============ Auto-save Recovery ============
function checkAutosaveRecovery() {
const saved = AutoSaveManager.load();
if (!saved) return;
document.getElementById('autosaveDate').textContent = formatDate(saved.savedAt);
openModal('autosaveModal');
}
function recoverAutosave() {
const saved = AutoSaveManager.load();
if (!saved) {
closeModal('autosaveModal');
return;
}
try {
const data = MigrationManager.migrate(saved);
AppState.project = data.project;
AppState.tags = data.tags || [];
AppState.activeBoardId = data.activeBoardId || AppState.project.boards[0]?.id || null;
CommandHistory.clear();
renderTabs();
renderBoard();
showToast('داده‌ها با موفقیت بازیابی شدند', 'success');
} catch (err) {
showToast('خطا در بازیابی: ' + err.message, 'error');
}
closeModal('autosaveModal');
}
function discardAutosave() {
AutoSaveManager.clear();
closeModal('autosaveModal');
showToast('ذخیره خودکار نادیده گرفته شد', 'info');
}
// ============ DateTime Footer ============
function updateDate() {
const now = new Date();
document.getElementById('currentDate').textContent = now.toLocaleDateString('fa-IR');
}
setInterval(updateDate, 60000);
updateDate();
// ============ Keyboard Shortcuts ============
document.addEventListener('keydown', (e) => {
if (e.ctrlKey && e.key === 's') {
e.preventDefault();
saveProject();
return;
}
if (e.ctrlKey && e.key === 'z') {
e.preventDefault();
CommandHistory.undo();
return;
}
if (e.ctrlKey && e.key === 'y') {
e.preventDefault();
CommandHistory.redo();
return;
}
if (e.ctrlKey && e.key === 'f') {
e.preventDefault();
document.getElementById('searchInput').focus();
return;
}
if (e.key === 'Escape') {
document.querySelectorAll('.modal-overlay.active').forEach(m => m.classList.remove('active'));
KeyboardManager.clearFocus();
return;
}
KeyboardManager.handleKey(e);
});
// ============ Search/Filter Events ============
document.getElementById('searchInput').addEventListener('input', () => SearchManager.apply());
document.getElementById('filterTag').addEventListener('change', () => SearchManager.apply());
document.getElementById('filterAssignee').addEventListener('change', () => SearchManager.apply());
document.getElementById('filterStatus').addEventListener('change', () => SearchManager.apply());
// ============ Initialize ============
function init() {
initTheme();
updateSidebarToggleIcon();
// Load webhook settings from storage
WebhookManager.loadFromStorage();
renderTabs();
renderBoard();
setTimeout(() => {
checkAutosaveRecovery();
}, 500);
AutoSaveManager.scheduleSave();
window.addEventListener('beforeunload', () => {
AutoSaveManager.save();
});
showToast('به xKanban خوش آمدید', 'success');
}
init();
</script>
</body>
</html>