4860 lines
148 KiB
HTML
4860 lines
148 KiB
HTML
<!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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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> |