Initial commit
Dieser Commit ist enthalten in:
540
frontend/css/admin.css
Normale Datei
540
frontend/css/admin.css
Normale Datei
@ -0,0 +1,540 @@
|
||||
/**
|
||||
* TASKMATE - Admin Styles
|
||||
* ========================
|
||||
* Styles fuer die Admin-Oberflaeche
|
||||
*/
|
||||
|
||||
/* Admin Screen */
|
||||
.admin-screen {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
background: var(--bg-main);
|
||||
}
|
||||
|
||||
.admin-screen.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* Admin Header */
|
||||
.admin-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 2rem;
|
||||
background: var(--bg-card);
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.admin-header h1 {
|
||||
font-size: 1.5rem;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.admin-header h1 svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
stroke: var(--primary);
|
||||
}
|
||||
|
||||
/* Admin Content */
|
||||
.admin-content {
|
||||
flex: 1;
|
||||
padding: 2rem;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Users Section */
|
||||
.admin-users-section {
|
||||
background: var(--bg-card);
|
||||
border-radius: var(--radius-xl);
|
||||
border: 1px solid var(--border-default);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.admin-users-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.admin-users-header h2 {
|
||||
font-size: 1.1rem;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* User List */
|
||||
.admin-users-list {
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.admin-user-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--border-light);
|
||||
gap: 1rem;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.admin-user-card:hover {
|
||||
border-color: var(--primary);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.admin-user-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: var(--radius-full);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-inverse);
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.admin-user-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-user-name {
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.admin-user-username {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.admin-user-badges {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.admin-badge {
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--font-medium);
|
||||
}
|
||||
|
||||
.admin-badge.role-admin {
|
||||
background: var(--error-bg);
|
||||
color: var(--error-text);
|
||||
}
|
||||
|
||||
.admin-badge.role-user {
|
||||
background: var(--primary-light);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.admin-badge.permission {
|
||||
background: var(--success-bg);
|
||||
color: var(--success-text);
|
||||
}
|
||||
|
||||
.admin-user-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.admin-user-actions button {
|
||||
padding: 0.5rem;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.admin-user-actions button:hover {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.admin-user-actions button.danger:hover {
|
||||
background: var(--error);
|
||||
border-color: var(--error);
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
/* User Modal - Verwendet Standard-Formularstyles aus components.css */
|
||||
.user-modal-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-5);
|
||||
}
|
||||
|
||||
.user-modal-form .form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.user-modal-form label {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-primary);
|
||||
font-weight: var(--font-medium);
|
||||
}
|
||||
|
||||
.user-modal-form label span {
|
||||
font-weight: normal;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.user-modal-form .form-hint {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Input-Felder im User Modal - nutzen Standard-Styles, aber mit sichtbarem Hintergrund */
|
||||
.user-modal-form input[type="text"],
|
||||
.user-modal-form input[type="password"],
|
||||
.user-modal-form select {
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--text-primary);
|
||||
font-size: var(--text-sm);
|
||||
font-family: var(--font-primary);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.user-modal-form input[type="text"]:hover,
|
||||
.user-modal-form input[type="password"]:hover,
|
||||
.user-modal-form select:hover {
|
||||
border-color: var(--border-dark);
|
||||
}
|
||||
|
||||
.user-modal-form input[type="text"]:focus,
|
||||
.user-modal-form input[type="password"]:focus,
|
||||
.user-modal-form select:focus {
|
||||
border-color: var(--primary);
|
||||
box-shadow: var(--shadow-focus);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.user-modal-form input[type="color"] {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
padding: 4px;
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-lg);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.user-modal-form select {
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20' viewBox='0 0 24 24' fill='none' stroke='%2364748B' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 10px center;
|
||||
background-size: 18px;
|
||||
padding-right: 40px;
|
||||
}
|
||||
|
||||
.permissions-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: var(--spacing-3);
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.permission-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
padding: var(--spacing-2);
|
||||
}
|
||||
|
||||
.permission-item input[type="checkbox"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
accent-color: var(--primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.permission-item label {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
font-weight: var(--font-medium);
|
||||
}
|
||||
|
||||
.permission-item .permission-desc {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* User Status */
|
||||
.admin-user-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.status-locked {
|
||||
color: var(--color-priority-high);
|
||||
}
|
||||
|
||||
.status-active {
|
||||
color: var(--color-priority-low);
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.admin-empty-state {
|
||||
padding: 3rem;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.admin-empty-state svg {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
fill: var(--text-muted);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* Input with Prefix */
|
||||
.input-with-prefix {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.input-prefix {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 12px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-default);
|
||||
border-right: none;
|
||||
border-radius: var(--radius-lg) 0 0 var(--radius-lg);
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.input-with-prefix input {
|
||||
border-radius: 0 var(--radius-lg) var(--radius-lg) 0 !important;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Form Hint */
|
||||
.form-hint {
|
||||
font-weight: normal;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
|
||||
/* =========================
|
||||
Upload Settings Section
|
||||
========================= */
|
||||
|
||||
.admin-upload-section {
|
||||
background: var(--bg-card);
|
||||
border-radius: var(--radius-xl);
|
||||
border: 1px solid var(--border-default);
|
||||
box-shadow: var(--shadow-sm);
|
||||
margin-top: 1.5rem;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.admin-section-header {
|
||||
margin-bottom: 1.5rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.admin-section-header h2 {
|
||||
font-size: 1.1rem;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.admin-section-header h2 svg {
|
||||
stroke: var(--primary);
|
||||
}
|
||||
|
||||
/* Upload Size Input */
|
||||
.admin-upload-size {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.admin-upload-size label {
|
||||
display: block;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.upload-size-input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
max-width: 150px;
|
||||
}
|
||||
|
||||
.upload-size-input input {
|
||||
width: 80px;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--text-primary);
|
||||
font-size: var(--text-sm);
|
||||
font-family: var(--font-primary);
|
||||
}
|
||||
|
||||
.upload-size-input input:focus {
|
||||
border-color: var(--primary);
|
||||
outline: none;
|
||||
box-shadow: var(--shadow-focus);
|
||||
}
|
||||
|
||||
.upload-size-unit {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
font-weight: var(--font-medium);
|
||||
}
|
||||
|
||||
/* Upload Types */
|
||||
.admin-upload-types {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.admin-upload-types h3 {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 1rem 0;
|
||||
}
|
||||
|
||||
/* Upload Category */
|
||||
.upload-category {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: var(--radius-lg);
|
||||
margin-bottom: 0.75rem;
|
||||
overflow: hidden;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.upload-category:hover {
|
||||
border-color: var(--border-default);
|
||||
}
|
||||
|
||||
.upload-category.disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.upload-category.disabled .upload-category-types {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.upload-category-header {
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
.upload-category-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.upload-category-toggle input[type="checkbox"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
accent-color: var(--primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.upload-category-title {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.upload-category-types {
|
||||
padding: 0.75rem 1rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.upload-type-tag {
|
||||
display: inline-block;
|
||||
padding: 4px 10px;
|
||||
background: var(--primary-light);
|
||||
color: var(--primary);
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--font-medium);
|
||||
}
|
||||
|
||||
/* Upload Actions */
|
||||
.admin-upload-actions {
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.admin-header {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.admin-content {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.admin-user-card {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.admin-user-badges {
|
||||
width: 100%;
|
||||
order: 3;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.user-modal-form .form-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
311
frontend/css/base.css
Normale Datei
311
frontend/css/base.css
Normale Datei
@ -0,0 +1,311 @@
|
||||
/**
|
||||
* TASKMATE - Base Styles
|
||||
* ======================
|
||||
* Reset und grundlegende Styles - Modernes Light Theme
|
||||
*/
|
||||
|
||||
/* Google Fonts Import */
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
|
||||
|
||||
/* Reset */
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: 16px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-primary);
|
||||
font-size: var(--text-base);
|
||||
line-height: var(--leading-normal);
|
||||
color: var(--text-primary);
|
||||
background-color: var(--bg-main);
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--scrollbar-bg);
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--scrollbar-thumb);
|
||||
border-radius: var(--radius-full);
|
||||
border: 2px solid var(--scrollbar-bg);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--scrollbar-thumb-hover);
|
||||
}
|
||||
|
||||
/* Firefox Scrollbar */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-bg);
|
||||
}
|
||||
|
||||
/* Selection */
|
||||
::selection {
|
||||
background: var(--primary);
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
/* Links */
|
||||
a {
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: var(--primary-hover);
|
||||
}
|
||||
|
||||
/* Headings */
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-family: var(--font-primary);
|
||||
font-weight: var(--font-semibold);
|
||||
line-height: var(--leading-tight);
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
h1 { font-size: var(--text-3xl); font-weight: var(--font-bold); }
|
||||
h2 { font-size: var(--text-2xl); }
|
||||
h3 { font-size: var(--text-xl); }
|
||||
h4 { font-size: var(--text-lg); }
|
||||
|
||||
/* Paragraphs */
|
||||
p {
|
||||
margin-bottom: var(--spacing-4);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Lists */
|
||||
ul, ol {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
/* Images */
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Focus States */
|
||||
:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--primary);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
/* Hidden */
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Screen reader only */
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* Text utilities */
|
||||
.text-primary { color: var(--text-primary); }
|
||||
.text-secondary { color: var(--text-secondary); }
|
||||
.text-tertiary { color: var(--text-tertiary); }
|
||||
.text-muted { color: var(--text-muted); }
|
||||
.text-accent { color: var(--primary); }
|
||||
.text-success { color: var(--success); }
|
||||
.text-warning { color: var(--warning); }
|
||||
.text-danger, .text-error { color: var(--error); }
|
||||
|
||||
/* Font weights */
|
||||
.font-normal { font-weight: var(--font-normal); }
|
||||
.font-medium { font-weight: var(--font-medium); }
|
||||
.font-semibold { font-weight: var(--font-semibold); }
|
||||
.font-bold { font-weight: var(--font-bold); }
|
||||
|
||||
/* Icons */
|
||||
.icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
color: currentColor;
|
||||
}
|
||||
|
||||
.icon-sm { width: 16px; height: 16px; }
|
||||
.icon-lg { width: 24px; height: 24px; }
|
||||
.icon-xl { width: 32px; height: 32px; }
|
||||
|
||||
/* Spinner */
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid var(--border-default);
|
||||
border-top-color: var(--primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.spinner-sm { width: 16px; height: 16px; border-width: 2px; }
|
||||
.spinner-lg { width: 32px; height: 32px; border-width: 3px; }
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Skeleton Loading */
|
||||
.skeleton {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--bg-tertiary) 0%,
|
||||
var(--bg-hover) 50%,
|
||||
var(--bg-tertiary) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s ease-in-out infinite;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: var(--spacing-8);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.empty-state .icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin-bottom: var(--spacing-4);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--spacing-2);
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-tertiary);
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
/* Divider */
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: var(--border-default);
|
||||
margin: var(--spacing-4) 0;
|
||||
}
|
||||
|
||||
/* Keyboard shortcuts */
|
||||
kbd {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 24px;
|
||||
height: 24px;
|
||||
padding: 0 var(--spacing-2);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: 0 1px 0 var(--border-dark);
|
||||
}
|
||||
|
||||
/* Truncate */
|
||||
.truncate {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Line clamp */
|
||||
.line-clamp-1 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.line-clamp-3 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Flex utilities */
|
||||
.flex { display: flex; }
|
||||
.flex-col { flex-direction: column; }
|
||||
.items-center { align-items: center; }
|
||||
.justify-center { justify-content: center; }
|
||||
.justify-between { justify-content: space-between; }
|
||||
.gap-1 { gap: var(--spacing-1); }
|
||||
.gap-2 { gap: var(--spacing-2); }
|
||||
.gap-3 { gap: var(--spacing-3); }
|
||||
.gap-4 { gap: var(--spacing-4); }
|
||||
|
||||
/* Cursor utilities */
|
||||
.cursor-pointer { cursor: pointer; }
|
||||
.cursor-grab { cursor: grab; }
|
||||
.cursor-grabbing { cursor: grabbing; }
|
||||
|
||||
/* Transition utility */
|
||||
.transition {
|
||||
transition: all var(--transition-default);
|
||||
}
|
||||
1096
frontend/css/board.css
Normale Datei
1096
frontend/css/board.css
Normale Datei
Datei-Diff unterdrückt, da er zu groß ist
Diff laden
763
frontend/css/calendar.css
Normale Datei
763
frontend/css/calendar.css
Normale Datei
@ -0,0 +1,763 @@
|
||||
/**
|
||||
* TASKMATE - Calendar Styles
|
||||
* ==========================
|
||||
* Kalender-Ansicht - Modernes Light Theme
|
||||
*/
|
||||
|
||||
/* ========================================
|
||||
CALENDAR VIEW
|
||||
======================================== */
|
||||
|
||||
.view-calendar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: var(--spacing-6);
|
||||
gap: var(--spacing-4);
|
||||
background: var(--bg-main);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Calendar Header */
|
||||
.calendar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-4) var(--spacing-5);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: var(--shadow-sm);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.calendar-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
}
|
||||
|
||||
.calendar-nav h2 {
|
||||
min-width: 200px;
|
||||
text-align: center;
|
||||
font-size: var(--text-lg);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.calendar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
}
|
||||
|
||||
/* View Toggle Buttons */
|
||||
.calendar-view-toggle {
|
||||
display: flex;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
.btn-toggle {
|
||||
padding: var(--spacing-2) var(--spacing-4);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn-toggle:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-toggle.active {
|
||||
background: var(--bg-card);
|
||||
color: var(--primary);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
/* Calendar Filter Checkbox */
|
||||
.calendar-filter-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
padding: var(--spacing-2) var(--spacing-3);
|
||||
border-radius: var(--radius-md);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.calendar-filter-checkbox:hover {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.calendar-filter-checkbox input[type="checkbox"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: var(--primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Calendar Filter Dropdown */
|
||||
.calendar-filter-dropdown {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.calendar-filter-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.calendar-filter-btn .icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.calendar-filter-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
margin-top: var(--spacing-2);
|
||||
min-width: 200px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-lg);
|
||||
z-index: 100;
|
||||
padding: var(--spacing-2);
|
||||
}
|
||||
|
||||
.calendar-filter-menu.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.calendar-filter-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
padding: var(--spacing-2) var(--spacing-3);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-md);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.calendar-filter-item:hover {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.calendar-filter-item input[type="checkbox"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: var(--primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.calendar-filter-item.checked {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Weekday Headers */
|
||||
.calendar-weekdays {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-light);
|
||||
border-bottom: none;
|
||||
border-radius: var(--radius-xl) var(--radius-xl) 0 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.calendar-weekday {
|
||||
padding: var(--spacing-3) var(--spacing-2);
|
||||
text-align: center;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-tertiary);
|
||||
border-right: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.calendar-weekday:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
/* Weekend headers */
|
||||
.calendar-weekday:nth-child(6),
|
||||
.calendar-weekday:nth-child(7) {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
/* Calendar Grid Container */
|
||||
.calendar-grid {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-light);
|
||||
border-top: none;
|
||||
border-radius: 0 0 var(--radius-xl) var(--radius-xl);
|
||||
box-shadow: var(--shadow-sm);
|
||||
overflow: hidden;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
/* Month View Grid */
|
||||
.calendar-grid.calendar-month-view {
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
grid-auto-rows: minmax(100px, 1fr);
|
||||
}
|
||||
|
||||
/* Week View Grid */
|
||||
.calendar-grid.calendar-week-view {
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
MONTH VIEW - CALENDAR DAY
|
||||
======================================== */
|
||||
|
||||
.calendar-day {
|
||||
padding: var(--spacing-2);
|
||||
border-right: 1px solid var(--border-light);
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast);
|
||||
background: var(--bg-card);
|
||||
min-height: 100px;
|
||||
overflow: visible;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.calendar-day:nth-child(7n) {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.calendar-day:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.calendar-day.other-month {
|
||||
opacity: 0.4;
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.calendar-day.today {
|
||||
background: var(--primary-light);
|
||||
}
|
||||
|
||||
.calendar-day-number {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
min-width: 28px;
|
||||
min-height: 28px;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--spacing-1);
|
||||
box-sizing: border-box;
|
||||
flex-shrink: 0;
|
||||
/* Reserve same space with transparent border for consistent sizing */
|
||||
border: 2px solid transparent;
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
.calendar-day.today .calendar-day-number {
|
||||
background: var(--primary);
|
||||
color: var(--text-inverse);
|
||||
/* Border is already set, no size change occurs */
|
||||
}
|
||||
|
||||
/* Calendar Tasks in Month View */
|
||||
.calendar-day-tasks {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
position: relative;
|
||||
margin-left: calc(-1 * var(--spacing-2));
|
||||
margin-right: calc(-1 * var(--spacing-2));
|
||||
padding-left: var(--spacing-2);
|
||||
padding-right: var(--spacing-2);
|
||||
}
|
||||
|
||||
.calendar-task {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
font-weight: var(--font-medium);
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius-sm);
|
||||
border-left: 3px solid #6B7280;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.calendar-task:hover {
|
||||
filter: brightness(0.92);
|
||||
}
|
||||
|
||||
.calendar-task-title {
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* User badges container - for multiple assignees */
|
||||
.calendar-task-badges {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* User badge - shows initials with user color */
|
||||
.calendar-task-user-badge {
|
||||
flex-shrink: 0;
|
||||
padding: 2px 5px;
|
||||
font-size: 10px;
|
||||
font-weight: var(--font-bold);
|
||||
color: white;
|
||||
background: #6B7280;
|
||||
border-radius: var(--radius-sm);
|
||||
text-shadow: 0 1px 1px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
.calendar-task.overdue {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.calendar-task.overdue::after {
|
||||
content: '!';
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
line-height: 14px;
|
||||
text-align: center;
|
||||
color: white;
|
||||
background: var(--error);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
/* Multi-day task range styles */
|
||||
.calendar-task.has-range {
|
||||
border-radius: 0;
|
||||
/* Extend beyond cell boundaries including 1px cell border for seamless connection */
|
||||
margin-left: calc(-1 * var(--spacing-2) - 1px);
|
||||
margin-right: calc(-1 * var(--spacing-2));
|
||||
padding: 4px var(--spacing-2);
|
||||
/* Fixed height for consistent bar thickness across all days */
|
||||
height: 24px;
|
||||
box-sizing: border-box;
|
||||
line-height: 16px;
|
||||
/* Transparent border for middle/end - color set via inline style on start */
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
|
||||
.calendar-task.has-range.range-start {
|
||||
border-radius: var(--radius-sm) 0 0 var(--radius-sm);
|
||||
/* Start doesn't extend left into previous cell */
|
||||
margin-left: 0;
|
||||
/* Compensate for the 1px difference to maintain same width */
|
||||
padding-left: calc(var(--spacing-2) + 1px);
|
||||
/* Border color set via inline style */
|
||||
}
|
||||
|
||||
.calendar-task.has-range.range-end {
|
||||
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
|
||||
/* End doesn't extend right into next cell */
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.calendar-task.has-range.range-middle {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
/* Search highlight - highlighted task in calendar search */
|
||||
.calendar-task.search-highlight,
|
||||
.calendar-week-task.search-highlight {
|
||||
border: 3px solid var(--primary);
|
||||
box-shadow: 0 0 12px rgba(37, 99, 235, 0.4);
|
||||
z-index: 10;
|
||||
position: relative;
|
||||
animation: pulse-highlight 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Continuous border for multi-day tasks */
|
||||
.calendar-task.search-highlight.has-range.range-start {
|
||||
border-right: none;
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
|
||||
.calendar-task.search-highlight.has-range.range-middle {
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.calendar-task.search-highlight.has-range.range-end {
|
||||
border-left: none;
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
|
||||
@keyframes pulse-highlight {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 12px rgba(37, 99, 235, 0.4);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 20px rgba(37, 99, 235, 0.6);
|
||||
}
|
||||
}
|
||||
|
||||
/* Hide user badge on middle/end parts of range */
|
||||
.calendar-task.has-range.range-middle .calendar-task-user-badge,
|
||||
.calendar-task.has-range.range-end .calendar-task-user-badge {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.calendar-more {
|
||||
padding: 2px 6px;
|
||||
font-size: 10px;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.calendar-more:hover {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
/* Weekends styling */
|
||||
.calendar-day:nth-child(7n-1),
|
||||
.calendar-day:nth-child(7n) {
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
/* Has tasks indicator */
|
||||
.calendar-day.has-tasks {
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
|
||||
/* ========================================
|
||||
WEEK VIEW
|
||||
======================================== */
|
||||
|
||||
.calendar-week-day {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid var(--border-light);
|
||||
background: var(--bg-card);
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.calendar-week-day:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.calendar-week-day.today {
|
||||
background: var(--primary-light);
|
||||
}
|
||||
|
||||
|
||||
/* Week Day Header */
|
||||
.calendar-week-day-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: var(--spacing-3);
|
||||
background: var(--bg-tertiary);
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.calendar-week-day-name {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.calendar-week-day-number {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
font-size: var(--text-lg);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--text-primary);
|
||||
border-radius: var(--radius-full);
|
||||
margin-top: var(--spacing-1);
|
||||
}
|
||||
|
||||
.calendar-week-day-number.today {
|
||||
background: var(--primary);
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
/* Week Day Tasks */
|
||||
.calendar-week-day-tasks {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
padding: var(--spacing-3);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.calendar-week-empty {
|
||||
text-align: center;
|
||||
padding: var(--spacing-4);
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.calendar-week-task {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
padding: var(--spacing-2) var(--spacing-3);
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius-lg);
|
||||
border-left: 3px solid #6B7280;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.calendar-week-task:hover {
|
||||
background: var(--bg-hover);
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
.calendar-week-task.overdue {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.calendar-week-task.overdue::after {
|
||||
content: '!';
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
font-size: 11px;
|
||||
font-weight: bold;
|
||||
line-height: 16px;
|
||||
text-align: center;
|
||||
color: white;
|
||||
background: var(--error);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.calendar-week-task-title {
|
||||
flex: 1;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Week view multi-day task range styles */
|
||||
.calendar-week-task.has-range {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.calendar-week-task.has-range.range-start::after {
|
||||
content: '▶';
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
font-size: 8px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.calendar-week-task.has-range.range-end::after {
|
||||
content: '◀';
|
||||
position: absolute;
|
||||
left: 28px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
font-size: 8px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.calendar-week-task.has-range.range-middle {
|
||||
opacity: 0.7;
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
/* Week Add Task Button */
|
||||
.calendar-week-add-task {
|
||||
margin: var(--spacing-2) var(--spacing-3) var(--spacing-3);
|
||||
padding: var(--spacing-2);
|
||||
background: transparent;
|
||||
border: 1px dashed var(--border-default);
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--text-sm);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.calendar-week-add-task:hover {
|
||||
background: var(--bg-tertiary);
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
DAY DETAIL POPUP
|
||||
======================================== */
|
||||
|
||||
.calendar-day-detail {
|
||||
position: fixed;
|
||||
min-width: 280px;
|
||||
max-width: 350px;
|
||||
padding: var(--spacing-4);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: var(--shadow-xl);
|
||||
z-index: var(--z-dropdown);
|
||||
}
|
||||
|
||||
.calendar-day-detail-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--spacing-4);
|
||||
padding-bottom: var(--spacing-3);
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.calendar-day-detail-date {
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.calendar-day-detail-tasks {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.calendar-detail-task {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
padding: var(--spacing-2) var(--spacing-3);
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius-lg);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.calendar-detail-task:hover {
|
||||
background: var(--bg-hover);
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
/* Priority Stars - siehe components.css */
|
||||
|
||||
.calendar-detail-title {
|
||||
flex: 1;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
RESPONSIVE
|
||||
======================================== */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.view-calendar {
|
||||
padding: var(--spacing-3);
|
||||
}
|
||||
|
||||
.calendar-header {
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-3);
|
||||
padding: var(--spacing-3);
|
||||
}
|
||||
|
||||
.calendar-nav {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.calendar-nav h2 {
|
||||
min-width: auto;
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
|
||||
.calendar-actions {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.calendar-weekday {
|
||||
font-size: var(--text-xs);
|
||||
padding: var(--spacing-2);
|
||||
}
|
||||
|
||||
.calendar-day {
|
||||
min-height: 60px;
|
||||
padding: var(--spacing-1);
|
||||
}
|
||||
|
||||
.calendar-day-number {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
min-width: 24px;
|
||||
min-height: 24px;
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.calendar-task {
|
||||
font-size: 9px;
|
||||
padding: 1px 4px;
|
||||
}
|
||||
|
||||
/* Week view on mobile - scroll horizontally */
|
||||
.calendar-grid.calendar-week-view {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.calendar-week-day {
|
||||
min-width: 140px;
|
||||
min-height: 300px;
|
||||
}
|
||||
}
|
||||
790
frontend/css/components.css
Normale Datei
790
frontend/css/components.css
Normale Datei
@ -0,0 +1,790 @@
|
||||
/**
|
||||
* TASKMATE - Components
|
||||
* =====================
|
||||
* Buttons, Inputs, Cards, etc. - Modernes Light Theme
|
||||
*/
|
||||
|
||||
/* ========================================
|
||||
BUTTONS
|
||||
======================================== */
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-2);
|
||||
padding: 0 var(--spacing-4);
|
||||
height: 40px;
|
||||
font-family: var(--font-primary);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
border: none;
|
||||
border-radius: var(--radius-lg);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-default);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Primary Button */
|
||||
.btn-primary {
|
||||
background: var(--primary);
|
||||
color: var(--text-inverse);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: var(--primary-hover);
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-primary:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
box-shadow: var(--shadow-xs);
|
||||
}
|
||||
|
||||
/* Secondary Button */
|
||||
.btn-secondary {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: var(--bg-tertiary);
|
||||
border-color: var(--border-dark);
|
||||
}
|
||||
|
||||
/* Ghost Button */
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.btn-ghost:hover:not(:disabled) {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Danger Button */
|
||||
.btn-danger {
|
||||
background: var(--error);
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: #DC2626;
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
/* Text Button */
|
||||
.btn-text {
|
||||
background: transparent;
|
||||
color: var(--primary);
|
||||
padding: 0 var(--spacing-2);
|
||||
height: auto;
|
||||
font-weight: var(--font-medium);
|
||||
}
|
||||
|
||||
.btn-text:hover:not(:disabled) {
|
||||
color: var(--primary-hover);
|
||||
background: var(--primary-light);
|
||||
}
|
||||
|
||||
/* Icon Button */
|
||||
.btn-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: var(--text-tertiary);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.btn-icon:hover:not(:disabled) {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Block Button */
|
||||
.btn-block {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Small Button */
|
||||
.btn-sm {
|
||||
height: 32px;
|
||||
padding: 0 var(--spacing-3);
|
||||
font-size: var(--text-xs);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
/* Large Button */
|
||||
.btn-lg {
|
||||
height: 48px;
|
||||
padding: 0 var(--spacing-6);
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
FORM ELEMENTS
|
||||
======================================== */
|
||||
|
||||
.form-group {
|
||||
margin-bottom: var(--spacing-4);
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: var(--spacing-2);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.form-group-large {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
/* Input */
|
||||
input[type="text"],
|
||||
input[type="email"],
|
||||
input[type="password"],
|
||||
input[type="number"],
|
||||
input[type="url"],
|
||||
input[type="date"],
|
||||
input[type="search"],
|
||||
textarea,
|
||||
select {
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
font-family: var(--font-primary);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-lg);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
input:hover,
|
||||
textarea:hover,
|
||||
select:hover {
|
||||
border-color: var(--border-dark);
|
||||
}
|
||||
|
||||
input:focus,
|
||||
textarea:focus,
|
||||
select:focus {
|
||||
border-color: var(--primary);
|
||||
box-shadow: var(--shadow-focus);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input::placeholder,
|
||||
textarea::placeholder {
|
||||
color: var(--text-placeholder);
|
||||
}
|
||||
|
||||
/* Select */
|
||||
select {
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20' viewBox='0 0 24 24' fill='none' stroke='%2364748B' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 10px center;
|
||||
background-size: 18px;
|
||||
padding-right: 40px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Multi-Select Dropdown */
|
||||
.multi-select-dropdown {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.multi-select-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
min-height: 42px;
|
||||
padding: 8px 14px;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-lg);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.multi-select-trigger:hover {
|
||||
border-color: var(--border-dark);
|
||||
}
|
||||
|
||||
.multi-select-dropdown.open .multi-select-trigger {
|
||||
border-color: var(--primary);
|
||||
box-shadow: var(--shadow-focus);
|
||||
}
|
||||
|
||||
.multi-select-placeholder {
|
||||
color: var(--text-placeholder);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.multi-select-selected {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.multi-select-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.multi-select-tag-avatar {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.multi-select-arrow {
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary);
|
||||
transition: transform var(--transition-fast);
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.multi-select-dropdown.open .multi-select-arrow {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.multi-select-dropdown.open {
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.multi-select-options {
|
||||
position: fixed;
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
background: #ffffff;
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||
z-index: 10000;
|
||||
}
|
||||
|
||||
.multi-select-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
background: #ffffff;
|
||||
transition: background var(--transition-fast);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.multi-select-option:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.multi-select-option input[type="checkbox"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.multi-select-option-avatar {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.multi-select-option-name {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.form-group-wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
/* Textarea */
|
||||
textarea {
|
||||
resize: vertical;
|
||||
min-height: 100px;
|
||||
line-height: var(--leading-relaxed);
|
||||
}
|
||||
|
||||
/* Checkbox & Radio */
|
||||
input[type="checkbox"],
|
||||
input[type="radio"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
accent-color: var(--primary);
|
||||
}
|
||||
|
||||
/* Color Input */
|
||||
input[type="color"] {
|
||||
width: 48px;
|
||||
height: 40px;
|
||||
padding: 4px;
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-lg);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Error Message */
|
||||
.error-message {
|
||||
padding: var(--spacing-3);
|
||||
margin-bottom: var(--spacing-4);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--error-text);
|
||||
background: var(--error-bg);
|
||||
border: 1px solid var(--error);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
/* Filter Select */
|
||||
.filter-select {
|
||||
padding: 6px 32px 6px 10px;
|
||||
font-size: var(--text-sm);
|
||||
background-size: 14px;
|
||||
min-width: 120px;
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
CARDS
|
||||
======================================== */
|
||||
|
||||
.card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: var(--spacing-5);
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: all var(--transition-default);
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
border-color: var(--border-default);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
BADGES & TAGS
|
||||
======================================== */
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 10px;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--font-medium);
|
||||
border-radius: var(--radius-full);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badge-default {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.badge-primary {
|
||||
background: var(--primary-light);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background: var(--success-bg);
|
||||
color: var(--success-text);
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
background: var(--warning-bg);
|
||||
color: var(--warning-text);
|
||||
}
|
||||
|
||||
.badge-error {
|
||||
background: var(--error-bg);
|
||||
color: var(--error-text);
|
||||
}
|
||||
|
||||
/* Priority Badges */
|
||||
.priority-high {
|
||||
background: var(--priority-high-bg);
|
||||
color: var(--priority-high);
|
||||
}
|
||||
|
||||
.priority-medium {
|
||||
background: var(--priority-medium-bg);
|
||||
color: var(--priority-medium);
|
||||
}
|
||||
|
||||
.priority-low {
|
||||
background: var(--priority-low-bg);
|
||||
color: var(--priority-low);
|
||||
}
|
||||
|
||||
/* Label Tag */
|
||||
.label-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-1);
|
||||
padding: 3px 10px;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--font-medium);
|
||||
border-radius: var(--radius-full);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.label-tag .remove {
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
margin-left: var(--spacing-1);
|
||||
}
|
||||
|
||||
.label-tag .remove:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
TOOLTIPS
|
||||
======================================== */
|
||||
|
||||
.tooltip {
|
||||
position: absolute;
|
||||
padding: 8px 12px;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-inverse);
|
||||
background: var(--text-primary);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-lg);
|
||||
z-index: var(--z-tooltip);
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
.tooltip.visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
TOAST NOTIFICATIONS
|
||||
======================================== */
|
||||
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
bottom: var(--spacing-6);
|
||||
right: var(--spacing-6);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-3);
|
||||
z-index: var(--z-toast);
|
||||
}
|
||||
|
||||
.toast {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
padding: var(--spacing-4);
|
||||
min-width: 320px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: var(--shadow-lg);
|
||||
animation: slideIn 0.3s ease;
|
||||
}
|
||||
|
||||
.toast-icon {
|
||||
flex-shrink: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.toast-message {
|
||||
flex: 1;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.toast.toast-success .toast-icon { color: var(--success); }
|
||||
.toast.toast-error .toast-icon { color: var(--error); }
|
||||
.toast.toast-warning .toast-icon { color: var(--warning); }
|
||||
.toast.toast-info .toast-icon { color: var(--info); }
|
||||
|
||||
.toast-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: var(--radius-md);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.toast-close:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(100%);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
DROPDOWN
|
||||
======================================== */
|
||||
|
||||
.dropdown {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
right: 0;
|
||||
min-width: 200px;
|
||||
padding: var(--spacing-2);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: var(--shadow-lg);
|
||||
z-index: var(--z-dropdown);
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
width: 100%;
|
||||
padding: var(--spacing-2) var(--spacing-3);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: var(--radius-lg);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.dropdown-item:hover {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.dropdown-item.text-danger {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.dropdown-item.text-danger:hover {
|
||||
background: var(--error-bg);
|
||||
}
|
||||
|
||||
.dropdown-divider {
|
||||
height: 1px;
|
||||
background: var(--border-light);
|
||||
margin: var(--spacing-2) 0;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
TABS
|
||||
======================================== */
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: var(--spacing-1);
|
||||
padding: var(--spacing-1);
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius-xl);
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: var(--spacing-2) var(--spacing-4);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--text-tertiary);
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: var(--radius-lg);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-card);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
PROGRESS BAR
|
||||
======================================== */
|
||||
|
||||
.progress {
|
||||
height: 8px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius-full);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 100%;
|
||||
background: var(--primary);
|
||||
border-radius: var(--radius-full);
|
||||
transition: width var(--transition-default);
|
||||
}
|
||||
|
||||
.progress-bar.success {
|
||||
background: var(--success);
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
AVATAR
|
||||
======================================== */
|
||||
|
||||
.avatar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--text-inverse);
|
||||
background: var(--primary);
|
||||
border-radius: var(--radius-full);
|
||||
border: 2px solid var(--bg-card);
|
||||
}
|
||||
|
||||
.avatar-sm { width: 28px; height: 28px; font-size: var(--text-xs); }
|
||||
.avatar-lg { width: 44px; height: 44px; font-size: var(--text-base); }
|
||||
|
||||
/* ========================================
|
||||
TABLE
|
||||
======================================== */
|
||||
|
||||
.task-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.task-table th,
|
||||
.task-table td {
|
||||
padding: var(--spacing-3) var(--spacing-4);
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.task-table th {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--text-tertiary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.task-table th.sortable {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.task-table th.sortable:hover {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.task-table tbody tr {
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.task-table tbody tr:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.task-table td {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.col-checkbox { width: 40px; }
|
||||
.col-priority { width: 100px; }
|
||||
.col-status { width: 120px; }
|
||||
.col-due { width: 120px; }
|
||||
.col-assignee { width: 120px; }
|
||||
|
||||
/* ========================================
|
||||
PRIORITY STARS
|
||||
======================================== */
|
||||
|
||||
.priority-stars {
|
||||
font-size: 12px;
|
||||
letter-spacing: -1px;
|
||||
flex-shrink: 0;
|
||||
cursor: default;
|
||||
background: transparent !important;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.priority-stars.priority-high {
|
||||
color: var(--priority-high);
|
||||
}
|
||||
|
||||
.priority-stars.priority-medium {
|
||||
color: var(--priority-medium);
|
||||
}
|
||||
|
||||
.priority-stars.priority-low {
|
||||
color: var(--priority-low);
|
||||
}
|
||||
291
frontend/css/dashboard.css
Normale Datei
291
frontend/css/dashboard.css
Normale Datei
@ -0,0 +1,291 @@
|
||||
/**
|
||||
* TASKMATE - Dashboard Styles
|
||||
* ===========================
|
||||
* Dashboard, Stats, Charts - Modernes Light Theme
|
||||
*/
|
||||
|
||||
/* ========================================
|
||||
DASHBOARD VIEW
|
||||
======================================== */
|
||||
|
||||
.view-dashboard {
|
||||
padding: var(--spacing-6);
|
||||
gap: var(--spacing-6);
|
||||
overflow-y: auto;
|
||||
background: var(--bg-main);
|
||||
}
|
||||
|
||||
/* Stats Grid */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: var(--spacing-5);
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-5);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: all var(--transition-default);
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-md);
|
||||
border-color: var(--border-default);
|
||||
}
|
||||
|
||||
.stat-card.stat-danger {
|
||||
border-left: 4px solid var(--error);
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: var(--radius-xl);
|
||||
}
|
||||
|
||||
.stat-icon svg {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
.stat-icon.stat-open {
|
||||
background: var(--primary-light);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.stat-icon.stat-progress {
|
||||
background: var(--warning-bg);
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.stat-icon.stat-done {
|
||||
background: var(--success-bg);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.stat-icon.stat-overdue {
|
||||
background: var(--error-bg);
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.stat-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-family: var(--font-primary);
|
||||
font-size: var(--text-2xl);
|
||||
font-weight: var(--font-bold);
|
||||
color: var(--text-primary);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-tertiary);
|
||||
margin-top: var(--spacing-1);
|
||||
}
|
||||
|
||||
/* Dashboard Row */
|
||||
.dashboard-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: var(--spacing-5);
|
||||
}
|
||||
|
||||
/* Dashboard Card */
|
||||
.dashboard-card {
|
||||
padding: var(--spacing-5);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.dashboard-card h3 {
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--font-semibold);
|
||||
margin-bottom: var(--spacing-4);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Chart Container */
|
||||
.chart-container {
|
||||
height: 200px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Simple Bar Chart */
|
||||
.bar-chart {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-around;
|
||||
height: 100%;
|
||||
padding-top: var(--spacing-4);
|
||||
}
|
||||
|
||||
.bar-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--spacing-1);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.bar {
|
||||
width: 36px;
|
||||
background: var(--primary);
|
||||
border-radius: var(--radius-md) var(--radius-md) 0 0;
|
||||
transition: all var(--transition-default);
|
||||
min-height: 4px;
|
||||
}
|
||||
|
||||
.bar:hover {
|
||||
background: var(--primary-hover);
|
||||
transform: scaleY(1.05);
|
||||
}
|
||||
|
||||
.bar-label {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.bar-value {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Horizontal Bar Chart */
|
||||
.horizontal-bar-chart {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.horizontal-bar-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-1);
|
||||
}
|
||||
|
||||
.horizontal-bar-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.horizontal-bar-label {
|
||||
color: var(--text-primary);
|
||||
font-weight: var(--font-medium);
|
||||
}
|
||||
|
||||
.horizontal-bar-value {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.horizontal-bar {
|
||||
height: 20px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius-full);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.horizontal-bar-fill {
|
||||
height: 100%;
|
||||
background: var(--primary);
|
||||
border-radius: var(--radius-full);
|
||||
transition: width var(--transition-default);
|
||||
}
|
||||
|
||||
/* Due Today List */
|
||||
.due-today-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-3);
|
||||
}
|
||||
|
||||
.due-today-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-4);
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius-xl);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.due-today-item:hover {
|
||||
background: var(--bg-hover);
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
.due-today-priority {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: var(--radius-full);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.due-today-priority.high { background: var(--priority-high); }
|
||||
.due-today-priority.medium { background: var(--priority-medium); }
|
||||
.due-today-priority.low { background: var(--priority-low); }
|
||||
|
||||
.due-today-title {
|
||||
flex: 1;
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.due-today-assignee {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
LIST VIEW
|
||||
======================================== */
|
||||
|
||||
.view-list {
|
||||
padding: var(--spacing-6);
|
||||
background: var(--bg-main);
|
||||
}
|
||||
|
||||
.list-container {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: var(--shadow-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Bulk Actions */
|
||||
.bulk-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-4) var(--spacing-5);
|
||||
background: var(--primary-light);
|
||||
border-top: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
.selected-count {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--primary);
|
||||
}
|
||||
577
frontend/css/gitea.css
Normale Datei
577
frontend/css/gitea.css
Normale Datei
@ -0,0 +1,577 @@
|
||||
/**
|
||||
* TASKMATE - Gitea Styles
|
||||
* =======================
|
||||
* Styles für die Git/Gitea-Integration
|
||||
*/
|
||||
|
||||
/* =============================================================================
|
||||
GITEA VIEW
|
||||
============================================================================= */
|
||||
|
||||
.view-gitea {
|
||||
padding: var(--spacing-6);
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
GITEA SECTIONS
|
||||
============================================================================= */
|
||||
|
||||
.gitea-section {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--spacing-6);
|
||||
margin-bottom: var(--spacing-4);
|
||||
}
|
||||
|
||||
.gitea-config-header {
|
||||
margin-bottom: var(--spacing-6);
|
||||
}
|
||||
|
||||
.gitea-config-header h2 {
|
||||
margin: 0 0 var(--spacing-2) 0;
|
||||
font-size: var(--text-xl);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.gitea-config-header p {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
CONNECTION STATUS
|
||||
============================================================================= */
|
||||
|
||||
.gitea-connection-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
padding: var(--spacing-3) var(--spacing-4);
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: var(--spacing-6);
|
||||
}
|
||||
|
||||
.gitea-connection-status .status-indicator {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--warning);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.gitea-connection-status.connected .status-indicator {
|
||||
background: var(--success);
|
||||
}
|
||||
|
||||
.gitea-connection-status.disconnected .status-indicator {
|
||||
background: var(--error);
|
||||
}
|
||||
|
||||
.gitea-connection-status .status-text {
|
||||
color: var(--text-primary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
CONFIG FORM
|
||||
============================================================================= */
|
||||
|
||||
.gitea-config-form .form-group {
|
||||
margin-bottom: var(--spacing-4);
|
||||
}
|
||||
|
||||
.gitea-repo-select-group {
|
||||
display: flex;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.gitea-repo-select-group select {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.gitea-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: var(--spacing-6) 0;
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.gitea-divider::before,
|
||||
.gitea-divider::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--border-default);
|
||||
}
|
||||
|
||||
.gitea-divider span {
|
||||
padding: 0 var(--spacing-4);
|
||||
}
|
||||
|
||||
.gitea-divider:empty::before {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.gitea-divider:empty::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
REPOSITORY HEADER
|
||||
============================================================================= */
|
||||
|
||||
.gitea-repo-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: var(--spacing-4);
|
||||
padding-bottom: var(--spacing-4);
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
.gitea-repo-header .repo-info h2 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
margin: 0 0 var(--spacing-2) 0;
|
||||
font-size: var(--text-xl);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.gitea-repo-header .repo-info h2 svg {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.gitea-repo-header .repo-url {
|
||||
display: block;
|
||||
color: var(--primary);
|
||||
font-size: var(--text-sm);
|
||||
text-decoration: none;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.gitea-repo-header .repo-url:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.gitea-repo-header .repo-actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.btn-danger-hover:hover {
|
||||
color: var(--error);
|
||||
border-color: var(--error);
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
LOCAL PATH DISPLAY
|
||||
============================================================================= */
|
||||
|
||||
.gitea-local-path {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
padding: var(--spacing-3) var(--spacing-4);
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: var(--spacing-4);
|
||||
}
|
||||
|
||||
.gitea-local-path .path-label {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.gitea-local-path code {
|
||||
font-family: monospace;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-primary);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
STATUS PANEL
|
||||
============================================================================= */
|
||||
|
||||
.gitea-status-panel {
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--spacing-4);
|
||||
margin-bottom: var(--spacing-4);
|
||||
}
|
||||
|
||||
.status-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.status-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-1);
|
||||
}
|
||||
|
||||
.status-label {
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--text-xs);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.branch-select {
|
||||
padding: var(--spacing-2) var(--spacing-3);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-default);
|
||||
background: var(--bg-input);
|
||||
color: var(--text-primary);
|
||||
font-size: var(--text-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.branch-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: var(--spacing-1) var(--spacing-2);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-badge.clean {
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.status-badge.dirty {
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.status-badge.ahead {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
color: var(--info);
|
||||
}
|
||||
|
||||
.status-badge.error {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.changes-count {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
#git-ahead-behind {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-primary);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
OPERATIONS PANEL
|
||||
============================================================================= */
|
||||
|
||||
.gitea-operations-panel {
|
||||
margin-bottom: var(--spacing-4);
|
||||
}
|
||||
|
||||
.gitea-operations-panel h3 {
|
||||
margin: 0 0 var(--spacing-4) 0;
|
||||
font-size: var(--text-base);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.operations-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-3);
|
||||
}
|
||||
|
||||
.operation-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.operation-btn svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.operation-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
CHANGES PANEL
|
||||
============================================================================= */
|
||||
|
||||
.gitea-changes-panel {
|
||||
margin-bottom: var(--spacing-4);
|
||||
}
|
||||
|
||||
.gitea-changes-panel h3 {
|
||||
margin: 0 0 var(--spacing-3) 0;
|
||||
font-size: var(--text-base);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.changes-list {
|
||||
max-height: 250px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.change-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
padding: var(--spacing-2) var(--spacing-3);
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.change-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.change-status {
|
||||
width: 20px;
|
||||
font-weight: 600;
|
||||
font-family: monospace;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.change-status.modified { color: var(--warning); }
|
||||
.change-status.added { color: var(--success); }
|
||||
.change-status.deleted { color: var(--error); }
|
||||
.change-status.untracked { color: var(--text-tertiary); }
|
||||
.change-status.renamed { color: var(--info); }
|
||||
|
||||
.change-file {
|
||||
flex: 1;
|
||||
color: var(--text-primary);
|
||||
word-break: break-all;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
COMMITS PANEL
|
||||
============================================================================= */
|
||||
|
||||
.gitea-commits-panel h3 {
|
||||
margin: 0 0 var(--spacing-3) 0;
|
||||
font-size: var(--text-base);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.commits-list {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.commit-item {
|
||||
display: flex;
|
||||
gap: var(--spacing-3);
|
||||
padding: var(--spacing-3) var(--spacing-4);
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
.commit-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.commit-item:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.commit-hash {
|
||||
font-family: monospace;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--primary);
|
||||
background: var(--bg-primary);
|
||||
padding: var(--spacing-1) var(--spacing-2);
|
||||
border-radius: var(--radius-sm);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.commit-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.commit-message {
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--spacing-1);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.commit-meta {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.commit-meta .author {
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
EMPTY STATE
|
||||
============================================================================= */
|
||||
|
||||
.gitea-empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: var(--spacing-12);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.gitea-empty-state svg {
|
||||
margin-bottom: var(--spacing-4);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.gitea-empty-state h3 {
|
||||
margin: 0 0 var(--spacing-2) 0;
|
||||
font-size: var(--text-lg);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.gitea-empty-state p {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
LOADING STATES
|
||||
============================================================================= */
|
||||
|
||||
.gitea-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--spacing-8);
|
||||
}
|
||||
|
||||
.gitea-loading .spinner {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 2px solid var(--border-default);
|
||||
border-top-color: var(--primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
FORM ENHANCEMENTS
|
||||
============================================================================= */
|
||||
|
||||
.form-hint {
|
||||
display: block;
|
||||
margin-top: var(--spacing-1);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.form-hint.success {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.form-hint.error {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.checkbox-label input[type="checkbox"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
RESPONSIVE
|
||||
============================================================================= */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.view-gitea {
|
||||
padding: var(--spacing-4);
|
||||
}
|
||||
|
||||
.gitea-section {
|
||||
padding: var(--spacing-4);
|
||||
}
|
||||
|
||||
.gitea-repo-header {
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-3);
|
||||
}
|
||||
|
||||
.gitea-repo-header .repo-actions {
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.status-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.operations-grid {
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.operation-btn {
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
min-width: 80px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.status-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.operations-grid {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.operation-btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
526
frontend/css/list.css
Normale Datei
526
frontend/css/list.css
Normale Datei
@ -0,0 +1,526 @@
|
||||
/**
|
||||
* TASKMATE - List View Styles
|
||||
* ===========================
|
||||
* Styles fuer die Listenansicht
|
||||
*/
|
||||
|
||||
/* List View Container */
|
||||
.view-list {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.view-list.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* List Header */
|
||||
.list-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--spacing-3) var(--spacing-4);
|
||||
background: var(--bg-card);
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.list-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* View Toggle */
|
||||
.list-view-toggle {
|
||||
display: flex;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.list-toggle-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-1);
|
||||
padding: var(--spacing-1) var(--spacing-3);
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--text-sm);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.list-toggle-btn svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.list-toggle-btn:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.list-toggle-btn.active {
|
||||
background: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
/* Sort Controls */
|
||||
.list-sort {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.list-sort label {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.list-sort select {
|
||||
padding: var(--spacing-1) var(--spacing-3);
|
||||
padding-right: 2rem;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-primary);
|
||||
font-size: var(--text-sm);
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%2364748B' stroke-width='2'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 8px center;
|
||||
}
|
||||
|
||||
.list-sort select:focus {
|
||||
border-color: var(--primary);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
#list-sort-direction {
|
||||
padding: var(--spacing-1);
|
||||
}
|
||||
|
||||
#list-sort-direction svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
transition: transform var(--transition-fast);
|
||||
}
|
||||
|
||||
#list-sort-direction.asc svg {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* List Content */
|
||||
.list-content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: var(--spacing-4);
|
||||
}
|
||||
|
||||
/* List Table */
|
||||
.list-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: var(--bg-card);
|
||||
border-radius: var(--radius-xl);
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.list-table-header {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 120px 100px 110px 140px;
|
||||
gap: var(--spacing-2);
|
||||
padding: var(--spacing-3) var(--spacing-4);
|
||||
background: var(--bg-tertiary);
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--text-tertiary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.list-table-header span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-1);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.list-table-header span:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.list-table-header span.sorted {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.list-table-header span svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
.list-table-header span.sorted svg {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.list-table-header span.sorted.desc svg {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* List Group */
|
||||
.list-group {
|
||||
margin-bottom: var(--spacing-4);
|
||||
}
|
||||
|
||||
.list-group:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.list-group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
padding: var(--spacing-2) var(--spacing-3);
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius-lg);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: all var(--transition-fast);
|
||||
margin-bottom: var(--spacing-2);
|
||||
}
|
||||
|
||||
.list-group-header:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.list-group-header svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--text-tertiary);
|
||||
transition: transform var(--transition-fast);
|
||||
}
|
||||
|
||||
.list-group-header.collapsed svg {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.list-group-title {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.list-group-color {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.list-group-count {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.list-group-content {
|
||||
background: var(--bg-card);
|
||||
border-radius: var(--radius-xl);
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.list-group-content.collapsed {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* List Row */
|
||||
.list-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 120px 100px 110px 140px;
|
||||
gap: var(--spacing-2);
|
||||
padding: var(--spacing-3) var(--spacing-4);
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
align-items: center;
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.list-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.list-row:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
/* List Cells */
|
||||
.list-cell {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-primary);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.list-cell-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
font-weight: var(--font-medium);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.list-cell-title:hover {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.list-cell-title span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Editable Cells */
|
||||
.list-cell-editable {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
padding: var(--spacing-1) var(--spacing-2);
|
||||
border-radius: var(--radius-md);
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.list-cell-editable:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.list-cell-editable.editing {
|
||||
background: var(--bg-input);
|
||||
}
|
||||
|
||||
/* Status Cell */
|
||||
.list-cell-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-1);
|
||||
}
|
||||
|
||||
.list-cell-status .status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.list-cell-status select {
|
||||
flex: 1;
|
||||
padding: var(--spacing-1);
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: inherit;
|
||||
font-size: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.list-cell-status select:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Priority Cell */
|
||||
.list-cell-priority {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--font-medium);
|
||||
}
|
||||
|
||||
.list-cell-priority.high {
|
||||
background: var(--error-bg);
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.list-cell-priority.medium {
|
||||
background: var(--warning-bg);
|
||||
color: var(--warning-text);
|
||||
}
|
||||
|
||||
.list-cell-priority.low {
|
||||
background: var(--success-bg);
|
||||
color: var(--success-text);
|
||||
}
|
||||
|
||||
.list-cell-priority select {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: inherit;
|
||||
font-size: inherit;
|
||||
font-weight: inherit;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.list-cell-priority select:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Date Cell */
|
||||
.list-cell-date {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.list-cell-date.overdue {
|
||||
color: var(--error);
|
||||
font-weight: var(--font-medium);
|
||||
}
|
||||
|
||||
.list-cell-date.today {
|
||||
color: var(--warning-text);
|
||||
font-weight: var(--font-medium);
|
||||
}
|
||||
|
||||
.list-cell-date input[type="date"] {
|
||||
width: 100%;
|
||||
padding: var(--spacing-1);
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-md);
|
||||
color: inherit;
|
||||
font-size: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.list-cell-date input[type="date"]:focus {
|
||||
border-color: var(--primary);
|
||||
background: var(--bg-input);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Assignee Cell */
|
||||
.list-cell-assignee {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.list-cell-assignee .avatar {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
font-weight: var(--font-semibold);
|
||||
color: white;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.list-cell-assignee select {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: inherit;
|
||||
font-size: inherit;
|
||||
cursor: pointer;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.list-cell-assignee select:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.list-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--spacing-8);
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.list-empty svg {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
margin-bottom: var(--spacing-4);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.list-empty h3 {
|
||||
font-size: var(--text-lg);
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--spacing-2);
|
||||
}
|
||||
|
||||
.list-empty p {
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
/* Inline Edit Input */
|
||||
.list-inline-input {
|
||||
width: 100%;
|
||||
padding: var(--spacing-1) var(--spacing-2);
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--primary);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-primary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.list-inline-input:focus {
|
||||
outline: none;
|
||||
box-shadow: var(--shadow-focus);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 1024px) {
|
||||
.list-table-header,
|
||||
.list-row {
|
||||
grid-template-columns: 1fr 100px 80px 100px 120px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.list-header {
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-3);
|
||||
}
|
||||
|
||||
.list-controls {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.list-view-toggle {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.list-sort {
|
||||
margin-left: 0;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.list-table-header,
|
||||
.list-row {
|
||||
grid-template-columns: 1fr 80px 70px;
|
||||
}
|
||||
|
||||
.list-table-header span:nth-child(4),
|
||||
.list-table-header span:nth-child(5),
|
||||
.list-row .list-cell:nth-child(4),
|
||||
.list-row .list-cell:nth-child(5) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
998
frontend/css/modal.css
Normale Datei
998
frontend/css/modal.css
Normale Datei
@ -0,0 +1,998 @@
|
||||
/**
|
||||
* TASKMATE - Modal Styles
|
||||
* =======================
|
||||
* Modals, Lightbox, Onboarding - Modernes Light Theme
|
||||
*/
|
||||
|
||||
/* ========================================
|
||||
MODAL OVERLAY
|
||||
======================================== */
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: var(--overlay-bg);
|
||||
backdrop-filter: blur(4px);
|
||||
z-index: var(--z-modal-overlay);
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition-default);
|
||||
}
|
||||
|
||||
.modal-overlay.visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
MODAL
|
||||
======================================== */
|
||||
|
||||
.modal {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%) scale(0.95);
|
||||
width: calc(100% - 32px);
|
||||
max-width: 500px;
|
||||
max-height: calc(100vh - 64px);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-2xl);
|
||||
box-shadow: var(--shadow-xl);
|
||||
z-index: var(--z-modal);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
opacity: 0;
|
||||
transition: all var(--transition-default);
|
||||
}
|
||||
|
||||
.modal.visible {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
|
||||
.modal-small {
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.modal-large {
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
/* Modal Header */
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--spacing-5) var(--spacing-6);
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
font-size: 20px;
|
||||
color: var(--text-muted);
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: var(--radius-lg);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
color: var(--error);
|
||||
background: var(--error-bg);
|
||||
}
|
||||
|
||||
/* Save Status Indicator (Auto-Save) */
|
||||
.save-status-indicator {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-muted);
|
||||
padding: var(--spacing-1) var(--spacing-3);
|
||||
border-radius: var(--radius-lg);
|
||||
margin-left: auto;
|
||||
margin-right: var(--spacing-3);
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
.save-status-indicator.saving {
|
||||
opacity: 1;
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.save-status-indicator.saved {
|
||||
opacity: 1;
|
||||
color: var(--success);
|
||||
animation: fadeOut 2s ease-in-out forwards;
|
||||
}
|
||||
|
||||
.save-status-indicator.error {
|
||||
opacity: 1;
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
@keyframes fadeOut {
|
||||
0%, 70% { opacity: 1; }
|
||||
100% { opacity: 0; }
|
||||
}
|
||||
|
||||
/* Modal Body */
|
||||
.modal-body {
|
||||
flex: 1;
|
||||
padding: var(--spacing-6);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Modal Footer */
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--spacing-4) var(--spacing-6);
|
||||
border-top: 1px solid var(--border-light);
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.modal-footer-left,
|
||||
.modal-footer-right {
|
||||
display: flex;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
TASK MODAL SPECIFIC
|
||||
======================================== */
|
||||
|
||||
/* Time Estimate Inputs */
|
||||
.time-estimate-inputs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.time-estimate-inputs input {
|
||||
width: 70px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Labels Select */
|
||||
.labels-select {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.label-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
padding: var(--spacing-2) var(--spacing-3);
|
||||
border-radius: var(--radius-lg);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.label-checkbox:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.label-checkbox input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.label-checkbox .checkmark {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 2px solid var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
transition: all var(--transition-fast);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.label-checkbox input:checked + .checkmark {
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.label-checkbox input:checked + .checkmark::after {
|
||||
content: '';
|
||||
display: block;
|
||||
width: 4px;
|
||||
height: 8px;
|
||||
border: solid var(--text-inverse);
|
||||
border-width: 0 2px 2px 0;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.label-color-dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
.btn-add-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-1);
|
||||
padding: var(--spacing-2) var(--spacing-3);
|
||||
color: var(--text-tertiary);
|
||||
background: none;
|
||||
border: 1px dashed var(--border-default);
|
||||
border-radius: var(--radius-lg);
|
||||
cursor: pointer;
|
||||
font-size: var(--text-sm);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn-add-label:hover {
|
||||
color: var(--primary);
|
||||
border-color: var(--primary);
|
||||
background: var(--primary-light);
|
||||
}
|
||||
|
||||
/* Subtasks */
|
||||
.subtask-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
margin-bottom: var(--spacing-3);
|
||||
}
|
||||
|
||||
.subtask-progress .progress {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.subtask-progress span {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.subtasks-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
margin-bottom: var(--spacing-3);
|
||||
}
|
||||
|
||||
.subtask-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
padding: var(--spacing-3);
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.subtask-item input[type="checkbox"] {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.subtask-item input[type="text"] {
|
||||
flex: 1;
|
||||
padding: var(--spacing-1) var(--spacing-2);
|
||||
font-size: var(--text-sm);
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.subtask-item input[type="text"]:focus {
|
||||
background: var(--bg-card);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.subtask-item.completed .subtask-title {
|
||||
text-decoration: line-through;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.subtask-delete {
|
||||
padding: 4px;
|
||||
color: var(--text-muted);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: all var(--transition-fast);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.subtask-item:hover .subtask-delete {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.subtask-delete:hover {
|
||||
color: var(--error);
|
||||
background: var(--error-bg);
|
||||
}
|
||||
|
||||
/* Add Subtask Form */
|
||||
.add-subtask-form {
|
||||
display: flex;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.add-subtask-form input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.subtask-title {
|
||||
flex: 1;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Links */
|
||||
.links-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
margin-bottom: var(--spacing-3);
|
||||
}
|
||||
|
||||
.link-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
padding: var(--spacing-3);
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.link-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.link-info {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.link-title {
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.link-url {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.link-actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-1);
|
||||
}
|
||||
|
||||
.add-link-form {
|
||||
display: flex;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.add-link-form input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Attachments */
|
||||
.attachments-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: var(--spacing-3);
|
||||
margin-bottom: var(--spacing-3);
|
||||
}
|
||||
|
||||
.attachment-item {
|
||||
position: relative;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: var(--radius-xl);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.attachment-preview {
|
||||
height: 100px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.attachment-preview img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.attachment-preview .icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.attachment-info {
|
||||
padding: var(--spacing-3);
|
||||
}
|
||||
|
||||
.attachment-name {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.attachment-size {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.attachment-actions {
|
||||
position: absolute;
|
||||
top: var(--spacing-2);
|
||||
right: var(--spacing-2);
|
||||
display: flex;
|
||||
gap: var(--spacing-1);
|
||||
opacity: 1;
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
.attachment-actions .btn {
|
||||
background: rgba(0, 0, 0, 0.6) !important;
|
||||
border: none !important;
|
||||
border-radius: var(--radius-md) !important;
|
||||
padding: 6px 8px !important;
|
||||
cursor: pointer;
|
||||
color: white !important;
|
||||
transition: all var(--transition-fast);
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
min-width: 28px;
|
||||
min-height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.attachment-actions .btn:hover {
|
||||
background: rgba(0, 0, 0, 0.85) !important;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.attachment-actions .btn:last-child:hover {
|
||||
background: var(--error) !important;
|
||||
}
|
||||
|
||||
/* File Upload */
|
||||
.file-upload-area {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-2);
|
||||
padding: var(--spacing-8);
|
||||
border: 2px dashed var(--border-default);
|
||||
border-radius: var(--radius-xl);
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.file-upload-area:hover,
|
||||
.file-upload-area.drag-over {
|
||||
border-color: var(--primary);
|
||||
background: var(--primary-light);
|
||||
}
|
||||
|
||||
.file-upload-area .icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.file-input-label {
|
||||
color: var(--primary);
|
||||
cursor: pointer;
|
||||
font-weight: var(--font-medium);
|
||||
}
|
||||
|
||||
.file-input-label:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.file-hint {
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
/* Comments */
|
||||
.comments-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
margin-bottom: var(--spacing-4);
|
||||
}
|
||||
|
||||
.comment-item {
|
||||
display: flex;
|
||||
gap: var(--spacing-3);
|
||||
}
|
||||
|
||||
.comment-avatar {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.comment-content {
|
||||
flex: 1;
|
||||
background: var(--bg-tertiary);
|
||||
padding: var(--spacing-3) var(--spacing-4);
|
||||
border-radius: var(--radius-xl);
|
||||
}
|
||||
|
||||
.comment-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--spacing-1);
|
||||
}
|
||||
|
||||
.comment-author {
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.comment-time {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.comment-text {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
line-height: var(--leading-relaxed);
|
||||
}
|
||||
|
||||
.comment-text p {
|
||||
margin-bottom: var(--spacing-1);
|
||||
}
|
||||
|
||||
.comment-text p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.add-comment-form {
|
||||
display: flex;
|
||||
gap: var(--spacing-3);
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.add-comment-form textarea {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* History */
|
||||
.history-container {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.history-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-3);
|
||||
padding: var(--spacing-3) 0;
|
||||
font-size: var(--text-sm);
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.history-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.history-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: var(--radius-full);
|
||||
margin-top: 6px;
|
||||
flex-shrink: 0;
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
.history-text {
|
||||
flex: 1;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.history-text strong {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.history-time {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Color Presets */
|
||||
.color-presets {
|
||||
display: flex;
|
||||
gap: var(--spacing-2);
|
||||
margin-bottom: var(--spacing-3);
|
||||
}
|
||||
|
||||
.color-preset {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 2px solid transparent;
|
||||
border-radius: var(--radius-lg);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.color-preset:hover,
|
||||
.color-preset.selected {
|
||||
border-color: var(--text-primary);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
CONFIRM MODAL
|
||||
======================================== */
|
||||
|
||||
#confirm-message {
|
||||
color: var(--text-secondary);
|
||||
line-height: var(--leading-relaxed);
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
SHORTCUTS MODAL
|
||||
======================================== */
|
||||
|
||||
.shortcuts-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: var(--spacing-3);
|
||||
}
|
||||
|
||||
.shortcut {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-3);
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.shortcut kbd {
|
||||
min-width: 28px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.shortcut span {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
LIGHTBOX
|
||||
======================================== */
|
||||
|
||||
.lightbox {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.95);
|
||||
z-index: calc(var(--z-modal) + 100);
|
||||
}
|
||||
|
||||
.lightbox-close {
|
||||
position: absolute;
|
||||
top: var(--spacing-6);
|
||||
right: var(--spacing-6);
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
font-size: 32px;
|
||||
color: white;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
.lightbox-close:hover {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
#lightbox-image {
|
||||
max-width: 90%;
|
||||
max-height: 90%;
|
||||
object-fit: contain;
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
ONBOARDING TOUR
|
||||
======================================== */
|
||||
|
||||
.onboarding-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
z-index: calc(var(--z-modal) + 50);
|
||||
}
|
||||
|
||||
.onboarding-tooltip {
|
||||
position: absolute;
|
||||
width: 340px;
|
||||
background: var(--bg-card);
|
||||
border: 2px solid var(--primary);
|
||||
border-radius: var(--radius-2xl);
|
||||
box-shadow: var(--shadow-xl);
|
||||
z-index: calc(var(--z-modal) + 51);
|
||||
}
|
||||
|
||||
.onboarding-content {
|
||||
padding: var(--spacing-6);
|
||||
}
|
||||
|
||||
.onboarding-content h3 {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--spacing-2);
|
||||
}
|
||||
|
||||
.onboarding-content p {
|
||||
color: var(--text-secondary);
|
||||
line-height: var(--leading-relaxed);
|
||||
}
|
||||
|
||||
.onboarding-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--spacing-4) var(--spacing-6);
|
||||
border-top: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
#onboarding-step {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
SETTINGS - COLOR PICKER
|
||||
======================================== */
|
||||
|
||||
.settings-divider {
|
||||
height: 1px;
|
||||
background: var(--border-light);
|
||||
margin: var(--spacing-6) 0;
|
||||
}
|
||||
|
||||
.color-picker-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.color-options {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.color-option {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: var(--radius-lg);
|
||||
border: 3px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.color-option:hover {
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.color-option.selected {
|
||||
border-color: var(--text-primary);
|
||||
box-shadow: 0 0 0 2px var(--bg-card), 0 0 0 4px var(--text-primary);
|
||||
}
|
||||
|
||||
.color-option.selected::after {
|
||||
content: '✓';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
color: white;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.color-custom-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
}
|
||||
|
||||
.color-custom-row input[type="color"] {
|
||||
width: 40px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border: 2px solid var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
}
|
||||
|
||||
.color-custom-row input[type="color"]::-webkit-color-swatch-wrapper {
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.color-custom-row input[type="color"]::-webkit-color-swatch {
|
||||
border-radius: var(--radius-sm);
|
||||
border: none;
|
||||
}
|
||||
|
||||
.color-current-display {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 2px solid var(--border-default);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.color-hex-display {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
min-width: 70px;
|
||||
}
|
||||
|
||||
.color-preview-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
}
|
||||
|
||||
#user-color-preview {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: var(--font-semibold);
|
||||
color: white;
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
ARCHIVE MODAL
|
||||
======================================== */
|
||||
|
||||
.archive-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.archive-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-4);
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: var(--radius-lg);
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.archive-item:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.archive-item-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
cursor: pointer;
|
||||
padding: var(--spacing-2);
|
||||
margin: calc(-1 * var(--spacing-2));
|
||||
border-radius: var(--radius-md);
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.archive-item-info:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.archive-item-title {
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--spacing-1);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.archive-item-info:hover .archive-item-title {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.archive-item-meta {
|
||||
display: flex;
|
||||
gap: var(--spacing-3);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.archive-item-meta span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-1);
|
||||
}
|
||||
|
||||
.archive-item-actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.archive-empty {
|
||||
text-align: center;
|
||||
padding: var(--spacing-8);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.priority-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--font-medium);
|
||||
}
|
||||
|
||||
.priority-badge.high {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #DC2626;
|
||||
}
|
||||
|
||||
.priority-badge.medium {
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
color: #D97706;
|
||||
}
|
||||
|
||||
.priority-badge.low {
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
color: #16A34A;
|
||||
}
|
||||
301
frontend/css/notifications.css
Normale Datei
301
frontend/css/notifications.css
Normale Datei
@ -0,0 +1,301 @@
|
||||
/**
|
||||
* TASKMATE - Notifications Styles
|
||||
* ================================
|
||||
* Styling für das Benachrichtigungssystem
|
||||
*/
|
||||
|
||||
/* =============================================================================
|
||||
NOTIFICATION BELL
|
||||
============================================================================= */
|
||||
|
||||
.notification-bell {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.notification-bell .btn-icon {
|
||||
position: relative;
|
||||
color: var(--text-tertiary);
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
.notification-bell.has-notifications .btn-icon {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.notification-bell .btn-icon:hover {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.notification-icon {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
/* Badge */
|
||||
.notification-badge {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
font-size: 11px;
|
||||
font-weight: var(--font-bold);
|
||||
color: white;
|
||||
background: var(--error);
|
||||
border-radius: var(--radius-full);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.notification-badge.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
NOTIFICATION DROPDOWN
|
||||
============================================================================= */
|
||||
|
||||
.notification-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
width: 380px;
|
||||
max-height: 520px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: var(--shadow-xl);
|
||||
z-index: var(--z-dropdown);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.notification-dropdown.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.notification-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--spacing-4);
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.notification-header h3 {
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.notification-header .btn-text {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--primary);
|
||||
padding: var(--spacing-1) var(--spacing-2);
|
||||
}
|
||||
|
||||
.notification-header .btn-text:hover {
|
||||
background: var(--primary-light);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
NOTIFICATION LIST
|
||||
============================================================================= */
|
||||
|
||||
.notification-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
max-height: 420px;
|
||||
}
|
||||
|
||||
/* Notification Item */
|
||||
.notification-item {
|
||||
display: flex;
|
||||
gap: var(--spacing-3);
|
||||
padding: var(--spacing-3) var(--spacing-4);
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.notification-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.notification-item:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.notification-item.unread {
|
||||
background: var(--primary-light);
|
||||
}
|
||||
|
||||
.notification-item.unread:hover {
|
||||
background: #E0E7FF;
|
||||
}
|
||||
|
||||
.notification-item.persistent {
|
||||
border-left: 3px solid var(--warning);
|
||||
padding-left: calc(var(--spacing-4) - 3px);
|
||||
}
|
||||
|
||||
/* Type Icon */
|
||||
.notification-type-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: var(--radius-full);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.notification-type-icon svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.notification-type-icon.task {
|
||||
background: var(--info-bg);
|
||||
color: var(--info);
|
||||
}
|
||||
|
||||
.notification-type-icon.comment {
|
||||
background: var(--success-bg);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.notification-type-icon.approval {
|
||||
background: var(--warning-bg);
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.notification-type-icon.mention {
|
||||
background: var(--primary-light);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.notification-type-icon.completed {
|
||||
background: var(--success-bg);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.notification-type-icon.priority {
|
||||
background: var(--error-bg);
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
/* Content */
|
||||
.notification-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.notification-title {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 2px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.notification-message {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.notification-time {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Actions */
|
||||
.notification-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-1);
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
.notification-item:hover .notification-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.notification-delete {
|
||||
padding: 4px;
|
||||
color: var(--text-muted);
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.notification-delete:hover {
|
||||
color: var(--error);
|
||||
background: var(--error-bg);
|
||||
}
|
||||
|
||||
.notification-delete svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
EMPTY STATE
|
||||
============================================================================= */
|
||||
|
||||
.notification-empty {
|
||||
padding: var(--spacing-8);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.notification-empty-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin: 0 auto var(--spacing-3);
|
||||
color: var(--text-muted);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.notification-empty p {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-sm);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
RESPONSIVE
|
||||
============================================================================= */
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.notification-dropdown {
|
||||
position: fixed;
|
||||
top: var(--header-height);
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
max-height: calc(100vh - var(--header-height));
|
||||
border-radius: 0;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.notification-list {
|
||||
max-height: calc(100vh - var(--header-height) - 60px);
|
||||
}
|
||||
}
|
||||
557
frontend/css/proposals.css
Normale Datei
557
frontend/css/proposals.css
Normale Datei
@ -0,0 +1,557 @@
|
||||
/**
|
||||
* TASKMATE - Proposals Styles
|
||||
* ============================
|
||||
* Styles fuer den Vorschlaege-Bereich
|
||||
*/
|
||||
|
||||
/* Proposals View */
|
||||
.proposals-view {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: 1.5rem;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.proposals-view.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* Proposals Header */
|
||||
.proposals-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.proposals-header h2 {
|
||||
font-size: 1.3rem;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.proposals-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.proposals-sort {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.proposals-sort label {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.proposals-sort select {
|
||||
padding: 0.5rem 2rem 0.5rem 0.75rem;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20' viewBox='0 0 24 24' fill='none' stroke='%2364748B' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 8px center;
|
||||
background-size: 16px;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.proposals-sort select:hover {
|
||||
border-color: var(--border-dark);
|
||||
}
|
||||
|
||||
.proposals-sort select:focus {
|
||||
border-color: var(--primary);
|
||||
box-shadow: var(--shadow-focus);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Proposals List */
|
||||
.proposals-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/* Proposal Card */
|
||||
.proposal-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: 1.25rem;
|
||||
transition: all var(--transition-fast);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.proposal-card:hover {
|
||||
border-color: var(--primary);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.proposal-card.approved {
|
||||
border-color: var(--success);
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
.proposal-card.approved::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: var(--success);
|
||||
border-radius: var(--radius-xl) var(--radius-xl) 0 0;
|
||||
}
|
||||
|
||||
.proposal-card.archived {
|
||||
opacity: 0.7;
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.proposal-card {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Proposal Badges Container */
|
||||
.proposal-badges {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Proposal Header */
|
||||
.proposal-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.proposal-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.proposal-approved-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: var(--success);
|
||||
color: var(--text-inverse);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.proposal-approved-badge svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
/* Archived Badge */
|
||||
.proposal-archived-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.proposal-archived-badge svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
stroke: currentColor;
|
||||
fill: none;
|
||||
}
|
||||
|
||||
/* Linked Task */
|
||||
.proposal-linked-task {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.proposal-linked-task svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
stroke: var(--primary);
|
||||
fill: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.proposal-linked-task span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Proposal Description */
|
||||
.proposal-description {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* Proposal Meta */
|
||||
.proposal-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.proposal-author {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.proposal-author-avatar {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.proposal-date {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Proposal Actions */
|
||||
.proposal-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
/* Vote Button */
|
||||
.proposal-vote-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.proposal-vote-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border-dark);
|
||||
}
|
||||
|
||||
.proposal-vote-btn.voted {
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
.proposal-vote-btn svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
stroke: currentColor;
|
||||
fill: none;
|
||||
}
|
||||
|
||||
.proposal-vote-count {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.proposal-vote-btn.own-proposal {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Approve Checkbox */
|
||||
.proposal-approve {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-lg);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.proposal-approve:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.proposal-approve input[type="checkbox"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
accent-color: var(--success);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.proposal-approve span {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.proposal-approve.approved {
|
||||
background: var(--success-bg);
|
||||
border-color: var(--success);
|
||||
}
|
||||
|
||||
.proposal-approve.approved span {
|
||||
color: var(--success-text);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Delete Button */
|
||||
.proposal-delete-btn {
|
||||
padding: 0.5rem;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.proposal-delete-btn:hover {
|
||||
background: var(--error);
|
||||
border-color: var(--error);
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
.proposal-delete-btn svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
stroke: currentColor;
|
||||
fill: none;
|
||||
}
|
||||
|
||||
/* Archive Button */
|
||||
.proposal-archive-btn {
|
||||
padding: 0.5rem;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.proposal-archive-btn:hover {
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
.proposal-archive-btn svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
stroke: currentColor;
|
||||
fill: none;
|
||||
}
|
||||
|
||||
/* Approved By Info */
|
||||
.proposal-approved-by {
|
||||
font-size: 0.8rem;
|
||||
color: var(--success-text);
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
/* Proposal Modal */
|
||||
.proposal-modal-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.proposal-modal-form .form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.proposal-modal-form label {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-primary);
|
||||
font-weight: var(--font-medium);
|
||||
}
|
||||
|
||||
.proposal-modal-form input,
|
||||
.proposal-modal-form textarea {
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--text-primary);
|
||||
font-size: var(--text-sm);
|
||||
font-family: var(--font-primary);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.proposal-modal-form input:hover,
|
||||
.proposal-modal-form textarea:hover {
|
||||
border-color: var(--border-dark);
|
||||
}
|
||||
|
||||
.proposal-modal-form textarea {
|
||||
min-height: 120px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.proposal-modal-form input:focus,
|
||||
.proposal-modal-form textarea:focus,
|
||||
.proposal-modal-form select:focus {
|
||||
border-color: var(--primary);
|
||||
box-shadow: var(--shadow-focus);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.proposal-modal-form select {
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--text-primary);
|
||||
font-size: var(--text-sm);
|
||||
font-family: var(--font-primary);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.proposal-modal-form select:hover {
|
||||
border-color: var(--border-dark);
|
||||
}
|
||||
|
||||
.proposal-modal-form select optgroup {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.proposal-modal-form select option {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.proposals-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4rem 2rem;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.proposals-empty svg {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
fill: var(--text-muted);
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.proposals-empty h3 {
|
||||
font-size: 1.1rem;
|
||||
color: var(--text-secondary);
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
|
||||
.proposals-empty p {
|
||||
font-size: 0.9rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Highlight Animation - for navigation from inbox */
|
||||
.proposal-card.highlight-pulse {
|
||||
animation: highlightPulse 2.5s ease-out;
|
||||
}
|
||||
|
||||
@keyframes highlightPulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 4px rgba(239, 68, 68, 0.7);
|
||||
border-color: #EF4444;
|
||||
}
|
||||
30% {
|
||||
box-shadow: 0 0 0 6px rgba(239, 68, 68, 0.5);
|
||||
border-color: #EF4444;
|
||||
}
|
||||
100% {
|
||||
box-shadow: var(--shadow-sm);
|
||||
border-color: var(--border-default);
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.proposals-view {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.proposals-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.proposals-controls {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.proposal-meta {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.proposal-actions {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
353
frontend/css/responsive.css
Normale Datei
353
frontend/css/responsive.css
Normale Datei
@ -0,0 +1,353 @@
|
||||
/**
|
||||
* TASKMATE - Responsive Styles
|
||||
* ============================
|
||||
* Mobile und Tablet Anpassungen
|
||||
*/
|
||||
|
||||
/* ========================================
|
||||
TABLET (max 1024px)
|
||||
======================================== */
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
:root {
|
||||
--column-width: 280px;
|
||||
--column-min-width: 260px;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.dashboard-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.shortcuts-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
SMALL TABLET (max 768px)
|
||||
======================================== */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.header {
|
||||
flex-wrap: wrap;
|
||||
height: auto;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.header-left {
|
||||
flex: 1;
|
||||
order: 1;
|
||||
}
|
||||
|
||||
.header-center {
|
||||
order: 3;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
padding-top: var(--spacing-sm);
|
||||
border-top: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
.header-right {
|
||||
order: 2;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
|
||||
.project-selector {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.view-tabs {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.view-tab {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.filter-actions {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.view-board {
|
||||
padding: var(--spacing-md);
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.column {
|
||||
width: calc(100vw - 32px);
|
||||
min-width: calc(100vw - 32px);
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.btn-add-column {
|
||||
width: calc(100vw - 32px);
|
||||
min-width: calc(100vw - 32px);
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
.modal {
|
||||
width: calc(100% - 16px);
|
||||
max-height: calc(100vh - 32px);
|
||||
}
|
||||
|
||||
.modal-large {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.add-link-form {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.attachments-container {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.task-table th,
|
||||
.task-table td {
|
||||
padding: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.col-labels {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.bulk-actions {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.calendar-day {
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
.calendar-task {
|
||||
font-size: 10px;
|
||||
padding: 1px 4px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
MOBILE (max 480px)
|
||||
======================================== */
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.header-right {
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.connection-status .status-text {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.user-dropdown {
|
||||
right: -50px;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.stat-icon svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: var(--text-xl);
|
||||
}
|
||||
|
||||
.view-calendar {
|
||||
padding: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.calendar-header {
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.calendar-header h2 {
|
||||
flex: 1;
|
||||
min-width: auto;
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
|
||||
.calendar-weekday {
|
||||
padding: var(--spacing-xs);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.calendar-day {
|
||||
padding: var(--spacing-xs);
|
||||
min-height: 60px;
|
||||
}
|
||||
|
||||
.calendar-day-number {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.calendar-day-tasks {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.calendar-day.has-tasks::after {
|
||||
content: '';
|
||||
display: block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: var(--accent);
|
||||
border-radius: 50%;
|
||||
margin: var(--spacing-xs) auto 0;
|
||||
}
|
||||
|
||||
.calendar-day.has-overdue::after {
|
||||
background: var(--error);
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.modal-footer-left,
|
||||
.modal-footer-right {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.time-estimate-inputs {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.attachments-container {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.toast-container {
|
||||
left: var(--spacing-md);
|
||||
right: var(--spacing-md);
|
||||
bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.toast {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
REDUCED MOTION
|
||||
======================================== */
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
PRINT STYLES
|
||||
======================================== */
|
||||
|
||||
@media print {
|
||||
.header,
|
||||
.filter-bar,
|
||||
.btn-add-column,
|
||||
.column-actions,
|
||||
.btn-add-task,
|
||||
.modal,
|
||||
.modal-overlay,
|
||||
.toast-container,
|
||||
.onboarding-overlay {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
body {
|
||||
background: white;
|
||||
color: black;
|
||||
}
|
||||
|
||||
.app {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.board {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.column {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-height: none;
|
||||
page-break-inside: avoid;
|
||||
margin-bottom: 20px;
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
|
||||
.task-card {
|
||||
page-break-inside: avoid;
|
||||
border: 1px solid #ddd;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
HIGH CONTRAST
|
||||
======================================== */
|
||||
|
||||
@media (prefers-contrast: high) {
|
||||
:root {
|
||||
--border-default: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.light-mode {
|
||||
--border-default: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.task-card,
|
||||
.column,
|
||||
.modal,
|
||||
.dropdown-menu {
|
||||
border-width: 2px;
|
||||
}
|
||||
}
|
||||
167
frontend/css/variables.css
Normale Datei
167
frontend/css/variables.css
Normale Datei
@ -0,0 +1,167 @@
|
||||
/**
|
||||
* TASKMATE - CSS Variables
|
||||
* ========================
|
||||
* Modernes Light-Theme Design System
|
||||
*/
|
||||
|
||||
:root {
|
||||
/* ========================================
|
||||
FARBEN - Modernes Light Theme
|
||||
======================================== */
|
||||
|
||||
/* Primärfarben */
|
||||
--primary: #4F46E5;
|
||||
--primary-hover: #4338CA;
|
||||
--primary-light: #EEF2FF;
|
||||
--accent: #06B6D4;
|
||||
--accent-hover: #0891B2;
|
||||
|
||||
/* Hintergründe */
|
||||
--bg-main: #F8FAFC;
|
||||
--bg-secondary: #FFFFFF;
|
||||
--bg-tertiary: #F1F5F9;
|
||||
--bg-hover: #E2E8F0;
|
||||
--bg-active: #CBD5E1;
|
||||
--bg-sidebar: #FFFFFF;
|
||||
--bg-card: #FFFFFF;
|
||||
--bg-input: #FFFFFF;
|
||||
|
||||
/* Textfarben */
|
||||
--text-primary: #0F172A;
|
||||
--text-secondary: #475569;
|
||||
--text-tertiary: #64748B;
|
||||
--text-muted: #94A3B8;
|
||||
--text-placeholder: #94A3B8;
|
||||
--text-inverse: #FFFFFF;
|
||||
|
||||
/* Statusfarben */
|
||||
--success: #10B981;
|
||||
--success-bg: #D1FAE5;
|
||||
--success-text: #065F46;
|
||||
--warning: #F59E0B;
|
||||
--warning-bg: #FEF3C7;
|
||||
--warning-text: #92400E;
|
||||
--error: #EF4444;
|
||||
--error-bg: #FEE2E2;
|
||||
--error-text: #991B1B;
|
||||
--info: #3B82F6;
|
||||
--info-bg: #DBEAFE;
|
||||
--info-text: #1E40AF;
|
||||
|
||||
/* Prioritätsfarben */
|
||||
--priority-high: #EF4444;
|
||||
--priority-high-bg: #FEE2E2;
|
||||
--priority-medium: #F59E0B;
|
||||
--priority-medium-bg: #FEF3C7;
|
||||
--priority-low: #10B981;
|
||||
--priority-low-bg: #D1FAE5;
|
||||
|
||||
/* Rahmen */
|
||||
--border-light: #F1F5F9;
|
||||
--border-default: #E2E8F0;
|
||||
--border-dark: #CBD5E1;
|
||||
--border-focus: #4F46E5;
|
||||
|
||||
/* Schatten */
|
||||
--shadow-xs: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||
--shadow-sm: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1);
|
||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1);
|
||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);
|
||||
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1);
|
||||
--shadow-focus: 0 0 0 3px rgba(79, 70, 229, 0.2);
|
||||
|
||||
/* Scrollbar */
|
||||
--scrollbar-bg: #F1F5F9;
|
||||
--scrollbar-thumb: #CBD5E1;
|
||||
--scrollbar-thumb-hover: #94A3B8;
|
||||
|
||||
/* Overlay */
|
||||
--overlay-bg: rgba(15, 23, 42, 0.5);
|
||||
|
||||
/* ========================================
|
||||
SCHRIFTARTEN
|
||||
======================================== */
|
||||
|
||||
--font-primary: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
--font-secondary: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', 'SF Mono', Monaco, 'Cascadia Code', monospace;
|
||||
|
||||
/* Schriftgrößen */
|
||||
--text-xs: 0.75rem;
|
||||
--text-sm: 0.875rem;
|
||||
--text-base: 1rem;
|
||||
--text-lg: 1.125rem;
|
||||
--text-xl: 1.25rem;
|
||||
--text-2xl: 1.5rem;
|
||||
--text-3xl: 1.875rem;
|
||||
|
||||
/* Zeilenhöhen */
|
||||
--leading-none: 1;
|
||||
--leading-tight: 1.25;
|
||||
--leading-normal: 1.5;
|
||||
--leading-relaxed: 1.625;
|
||||
|
||||
/* Schriftgewichte */
|
||||
--font-normal: 400;
|
||||
--font-medium: 500;
|
||||
--font-semibold: 600;
|
||||
--font-bold: 700;
|
||||
|
||||
/* ========================================
|
||||
ABSTÄNDE & GRÖSSEN
|
||||
======================================== */
|
||||
|
||||
--spacing-0: 0;
|
||||
--spacing-1: 0.25rem;
|
||||
--spacing-2: 0.5rem;
|
||||
--spacing-3: 0.75rem;
|
||||
--spacing-4: 1rem;
|
||||
--spacing-5: 1.25rem;
|
||||
--spacing-6: 1.5rem;
|
||||
--spacing-8: 2rem;
|
||||
--spacing-10: 2.5rem;
|
||||
--spacing-12: 3rem;
|
||||
|
||||
/* Alte Spacing-Namen für Kompatibilität */
|
||||
--spacing-xs: 0.25rem;
|
||||
--spacing-sm: 0.5rem;
|
||||
--spacing-md: 1rem;
|
||||
--spacing-lg: 1.5rem;
|
||||
--spacing-xl: 2rem;
|
||||
--spacing-2xl: 2.5rem;
|
||||
|
||||
/* Border Radius */
|
||||
--radius-none: 0;
|
||||
--radius-sm: 0.25rem;
|
||||
--radius-md: 0.375rem;
|
||||
--radius-lg: 0.5rem;
|
||||
--radius-xl: 0.75rem;
|
||||
--radius-2xl: 1rem;
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* Komponentengrößen */
|
||||
--header-height: 64px;
|
||||
--sidebar-width: 280px;
|
||||
--column-width: 320px;
|
||||
--column-min-width: 300px;
|
||||
--card-min-height: 72px;
|
||||
|
||||
/* ========================================
|
||||
TRANSITIONS
|
||||
======================================== */
|
||||
|
||||
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--transition-default: 200ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--transition-slow: 300ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
/* ========================================
|
||||
Z-INDEX
|
||||
======================================== */
|
||||
|
||||
--z-dropdown: 100;
|
||||
--z-sticky: 200;
|
||||
--z-modal-overlay: 900;
|
||||
--z-modal: 1000;
|
||||
--z-tooltip: 1100;
|
||||
--z-toast: 1200;
|
||||
}
|
||||
1233
frontend/index.html
Normale Datei
1233
frontend/index.html
Normale Datei
Datei-Diff unterdrückt, da er zu groß ist
Diff laden
505
frontend/js/admin.js
Normale Datei
505
frontend/js/admin.js
Normale Datei
@ -0,0 +1,505 @@
|
||||
/**
|
||||
* TASKMATE - Admin Dashboard
|
||||
* ==========================
|
||||
* Benutzerverwaltung fuer Administratoren
|
||||
*/
|
||||
|
||||
import api from './api.js';
|
||||
import { $, $$ } from './utils.js';
|
||||
import authManager from './auth.js';
|
||||
import store from './store.js';
|
||||
|
||||
class AdminManager {
|
||||
constructor() {
|
||||
this.users = [];
|
||||
this.currentEditUser = null;
|
||||
this.uploadSettings = null;
|
||||
this.initialized = false;
|
||||
}
|
||||
|
||||
async init() {
|
||||
console.log('[Admin] init() called, initialized:', this.initialized);
|
||||
|
||||
if (this.initialized) {
|
||||
await this.loadUsers();
|
||||
await this.loadUploadSettings();
|
||||
return;
|
||||
}
|
||||
|
||||
// DOM Elements - erst bei init() laden
|
||||
this.adminScreen = $('#admin-screen');
|
||||
console.log('[Admin] adminScreen found:', !!this.adminScreen);
|
||||
this.usersList = $('#admin-users-list');
|
||||
this.logoutBtn = $('#admin-logout-btn');
|
||||
this.newUserBtn = $('#btn-new-user');
|
||||
|
||||
// Modal Elements
|
||||
this.userModal = $('#user-modal');
|
||||
this.userModalTitle = $('#user-modal-title');
|
||||
this.userForm = $('#user-form');
|
||||
this.editUserId = $('#edit-user-id');
|
||||
this.usernameInput = $('#user-username');
|
||||
this.displayNameInput = $('#user-displayname');
|
||||
this.emailInput = $('#user-email');
|
||||
this.passwordInput = $('#user-password');
|
||||
this.passwordHint = $('#password-hint');
|
||||
this.roleSelect = $('#user-role');
|
||||
this.permissionsGroup = $('#permissions-group');
|
||||
this.permGenehmigung = $('#perm-genehmigung');
|
||||
this.deleteUserBtn = $('#btn-delete-user');
|
||||
this.unlockUserBtn = $('#btn-unlock-user');
|
||||
|
||||
// Upload Settings Elements
|
||||
this.uploadMaxSizeInput = $('#upload-max-size');
|
||||
this.saveUploadSettingsBtn = $('#btn-save-upload-settings');
|
||||
this.uploadCategories = $$('.upload-category');
|
||||
|
||||
this.bindEvents();
|
||||
this.initialized = true;
|
||||
|
||||
await this.loadUsers();
|
||||
await this.loadUploadSettings();
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
// Logout
|
||||
this.logoutBtn?.addEventListener('click', () => this.handleLogout());
|
||||
|
||||
// New User Button
|
||||
this.newUserBtn?.addEventListener('click', () => this.openNewUserModal());
|
||||
|
||||
// User Form Submit
|
||||
this.userForm?.addEventListener('submit', (e) => this.handleUserSubmit(e));
|
||||
|
||||
// Role Change - hide permissions for admin
|
||||
this.roleSelect?.addEventListener('change', () => this.togglePermissionsVisibility());
|
||||
|
||||
// Delete User Button
|
||||
this.deleteUserBtn?.addEventListener('click', () => this.handleDeleteUser());
|
||||
|
||||
// Unlock User Button
|
||||
this.unlockUserBtn?.addEventListener('click', () => this.handleUnlockUser());
|
||||
|
||||
// Modal close buttons
|
||||
this.userModal?.querySelectorAll('[data-close-modal]').forEach(btn => {
|
||||
btn.addEventListener('click', () => this.closeModal());
|
||||
});
|
||||
|
||||
// Upload Settings - Save Button
|
||||
this.saveUploadSettingsBtn?.addEventListener('click', () => this.saveUploadSettings());
|
||||
|
||||
// Upload Settings - Category Toggles
|
||||
this.uploadCategories?.forEach(category => {
|
||||
const checkbox = category.querySelector('input[type="checkbox"]');
|
||||
checkbox?.addEventListener('change', () => {
|
||||
this.toggleUploadCategory(category, checkbox.checked);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async loadUsers() {
|
||||
try {
|
||||
this.users = await api.getAdminUsers();
|
||||
this.renderUsers();
|
||||
} catch (error) {
|
||||
console.error('Error loading users:', error);
|
||||
this.showToast('Fehler beim Laden der Benutzer', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
renderUsers() {
|
||||
if (!this.usersList) return;
|
||||
|
||||
if (this.users.length === 0) {
|
||||
this.usersList.innerHTML = `
|
||||
<div class="admin-empty-state">
|
||||
<svg viewBox="0 0 24 24"><path d="M12 2a5 5 0 0 1 5 5v2a5 5 0 0 1-10 0V7a5 5 0 0 1 5-5zm-7 18a7 7 0 0 1 14 0" stroke="currentColor" stroke-width="2" fill="none"/></svg>
|
||||
<p>Keine Benutzer vorhanden</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
this.usersList.innerHTML = this.users.map(user => this.renderUserCard(user)).join('');
|
||||
|
||||
// Bind edit buttons
|
||||
this.usersList.querySelectorAll('.admin-user-card').forEach(card => {
|
||||
const userId = parseInt(card.dataset.userId);
|
||||
const editBtn = card.querySelector('.btn-edit-user');
|
||||
editBtn?.addEventListener('click', () => this.openEditUserModal(userId));
|
||||
});
|
||||
}
|
||||
|
||||
renderUserCard(user) {
|
||||
const initial = (user.display_name || user.username).charAt(0).toUpperCase();
|
||||
const isLocked = user.locked_until && new Date(user.locked_until) > new Date();
|
||||
const permissions = user.permissions || [];
|
||||
|
||||
return `
|
||||
<div class="admin-user-card" data-user-id="${user.id}">
|
||||
<div class="admin-user-avatar" style="background-color: ${user.color || '#808080'}">
|
||||
${initial}
|
||||
</div>
|
||||
<div class="admin-user-info">
|
||||
<div class="admin-user-name">${this.escapeHtml(user.display_name)}</div>
|
||||
<div class="admin-user-username">@${this.escapeHtml(user.username)}${user.email ? ` · ${this.escapeHtml(user.email)}` : ''}</div>
|
||||
</div>
|
||||
<div class="admin-user-badges">
|
||||
<span class="admin-badge role-${user.role || 'user'}">
|
||||
${user.role === 'admin' ? 'Admin' : 'Benutzer'}
|
||||
</span>
|
||||
${permissions.map(p => `<span class="admin-badge permission">${this.escapeHtml(p)}</span>`).join('')}
|
||||
</div>
|
||||
<div class="admin-user-status">
|
||||
${isLocked ? '<span class="status-locked">Gesperrt</span>' : '<span class="status-active">Aktiv</span>'}
|
||||
</div>
|
||||
<div class="admin-user-actions">
|
||||
<button class="btn-edit-user" title="Bearbeiten">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" stroke="currentColor" stroke-width="2" fill="none"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" stroke="currentColor" stroke-width="2" fill="none"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
generateRandomPassword(length = 10) {
|
||||
const upper = 'ABCDEFGHJKLMNPQRSTUVWXYZ';
|
||||
const lower = 'abcdefghjkmnpqrstuvwxyz';
|
||||
const numbers = '23456789';
|
||||
const special = '!@#$%&*?';
|
||||
|
||||
// Mindestens ein Zeichen aus jeder Kategorie
|
||||
let password = '';
|
||||
password += upper.charAt(Math.floor(Math.random() * upper.length));
|
||||
password += lower.charAt(Math.floor(Math.random() * lower.length));
|
||||
password += numbers.charAt(Math.floor(Math.random() * numbers.length));
|
||||
password += special.charAt(Math.floor(Math.random() * special.length));
|
||||
|
||||
// Rest mit gemischten Zeichen auffüllen
|
||||
const allChars = upper + lower + numbers + special;
|
||||
for (let i = password.length; i < length; i++) {
|
||||
password += allChars.charAt(Math.floor(Math.random() * allChars.length));
|
||||
}
|
||||
|
||||
// Passwort mischen
|
||||
return password.split('').sort(() => Math.random() - 0.5).join('');
|
||||
}
|
||||
|
||||
openNewUserModal() {
|
||||
this.currentEditUser = null;
|
||||
this.userModalTitle.textContent = 'Neuer Benutzer';
|
||||
this.userForm.reset();
|
||||
this.editUserId.value = '';
|
||||
|
||||
// Passwort-Feld anzeigen (falls bei Bearbeitung versteckt)
|
||||
this.passwordInput.closest('.form-group').style.display = '';
|
||||
|
||||
// Zufälliges Passwort generieren und anzeigen (10 Zeichen mit Sonderzeichen)
|
||||
const randomPassword = this.generateRandomPassword(10);
|
||||
this.passwordInput.value = randomPassword;
|
||||
this.passwordInput.readOnly = true;
|
||||
this.passwordInput.type = 'text';
|
||||
this.passwordHint.textContent = '(automatisch generiert)';
|
||||
|
||||
this.usernameInput.disabled = false;
|
||||
this.emailInput.disabled = false;
|
||||
this.deleteUserBtn.classList.add('hidden');
|
||||
this.unlockUserBtn.classList.add('hidden');
|
||||
this.togglePermissionsVisibility();
|
||||
this.openModal();
|
||||
}
|
||||
|
||||
openEditUserModal(userId) {
|
||||
const user = this.users.find(u => u.id === userId);
|
||||
if (!user) return;
|
||||
|
||||
this.currentEditUser = user;
|
||||
this.userModalTitle.textContent = 'Benutzer bearbeiten';
|
||||
this.editUserId.value = user.id;
|
||||
this.usernameInput.value = user.username;
|
||||
this.usernameInput.disabled = true; // Username cannot be changed
|
||||
this.displayNameInput.value = user.display_name;
|
||||
this.emailInput.value = user.email || '';
|
||||
this.emailInput.disabled = false;
|
||||
|
||||
// Passwort-Feld bei Bearbeitung ausblenden
|
||||
this.passwordInput.closest('.form-group').style.display = 'none';
|
||||
|
||||
this.roleSelect.value = user.role || 'user';
|
||||
|
||||
// Set permissions
|
||||
const permissions = user.permissions || [];
|
||||
this.permGenehmigung.checked = permissions.includes('genehmigung');
|
||||
|
||||
// Show/hide delete button (cannot delete self or last admin)
|
||||
const canDelete = user.id !== authManager.getUser()?.id;
|
||||
this.deleteUserBtn.classList.toggle('hidden', !canDelete);
|
||||
|
||||
// Show unlock button if user is locked
|
||||
const isLocked = user.locked_until && new Date(user.locked_until) > new Date();
|
||||
this.unlockUserBtn.classList.toggle('hidden', !isLocked);
|
||||
|
||||
this.togglePermissionsVisibility();
|
||||
this.openModal();
|
||||
}
|
||||
|
||||
togglePermissionsVisibility() {
|
||||
const isAdmin = this.roleSelect.value === 'admin';
|
||||
this.permissionsGroup.style.display = isAdmin ? 'none' : 'block';
|
||||
}
|
||||
|
||||
async handleUserSubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const userId = this.editUserId.value;
|
||||
const isEdit = !!userId;
|
||||
|
||||
const data = {
|
||||
displayName: this.displayNameInput.value.trim(),
|
||||
email: this.emailInput.value.trim(),
|
||||
role: this.roleSelect.value,
|
||||
permissions: this.roleSelect.value === 'admin' ? [] : this.getSelectedPermissions()
|
||||
};
|
||||
|
||||
if (!isEdit) {
|
||||
data.username = this.usernameInput.value.trim().toUpperCase();
|
||||
}
|
||||
|
||||
if (this.passwordInput.value) {
|
||||
data.password = this.passwordInput.value;
|
||||
}
|
||||
|
||||
try {
|
||||
if (isEdit) {
|
||||
await api.updateAdminUser(userId, data);
|
||||
this.showToast('Benutzer aktualisiert', 'success');
|
||||
} else {
|
||||
await api.createAdminUser(data);
|
||||
this.showToast('Benutzer erstellt', 'success');
|
||||
}
|
||||
|
||||
this.closeModal();
|
||||
await this.loadUsers();
|
||||
} catch (error) {
|
||||
this.showToast(error.message || 'Fehler beim Speichern', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async handleDeleteUser() {
|
||||
if (!this.currentEditUser) return;
|
||||
|
||||
const confirmDelete = confirm(`Benutzer "${this.currentEditUser.display_name}" wirklich löschen?`);
|
||||
if (!confirmDelete) return;
|
||||
|
||||
try {
|
||||
await api.deleteAdminUser(this.currentEditUser.id);
|
||||
this.showToast('Benutzer gelöscht', 'success');
|
||||
this.closeModal();
|
||||
await this.loadUsers();
|
||||
} catch (error) {
|
||||
this.showToast(error.message || 'Fehler beim Löschen', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async handleUnlockUser() {
|
||||
if (!this.currentEditUser) return;
|
||||
|
||||
try {
|
||||
await api.updateAdminUser(this.currentEditUser.id, { unlockAccount: true });
|
||||
this.showToast('Benutzer entsperrt', 'success');
|
||||
this.closeModal();
|
||||
await this.loadUsers();
|
||||
} catch (error) {
|
||||
this.showToast(error.message || 'Fehler beim Entsperren', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
getSelectedPermissions() {
|
||||
const permissions = [];
|
||||
if (this.permGenehmigung?.checked) {
|
||||
permissions.push('genehmigung');
|
||||
}
|
||||
return permissions;
|
||||
}
|
||||
|
||||
async handleLogout() {
|
||||
try {
|
||||
await authManager.logout();
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
openModal() {
|
||||
if (this.userModal) {
|
||||
this.userModal.classList.remove('hidden');
|
||||
this.userModal.classList.add('visible');
|
||||
}
|
||||
const overlay = $('#modal-overlay');
|
||||
if (overlay) {
|
||||
overlay.classList.remove('hidden');
|
||||
overlay.classList.add('visible');
|
||||
}
|
||||
store.openModal('user-modal');
|
||||
}
|
||||
|
||||
closeModal() {
|
||||
if (this.userModal) {
|
||||
this.userModal.classList.remove('visible');
|
||||
this.userModal.classList.add('hidden');
|
||||
}
|
||||
// Only hide overlay if no other modals are open
|
||||
const openModals = store.get('openModals').filter(id => id !== 'user-modal');
|
||||
if (openModals.length === 0) {
|
||||
const overlay = $('#modal-overlay');
|
||||
if (overlay) {
|
||||
overlay.classList.remove('visible');
|
||||
overlay.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
store.closeModal('user-modal');
|
||||
}
|
||||
|
||||
showToast(message, type = 'info') {
|
||||
window.dispatchEvent(new CustomEvent('toast:show', {
|
||||
detail: { message, type }
|
||||
}));
|
||||
}
|
||||
|
||||
escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// =====================
|
||||
// UPLOAD SETTINGS
|
||||
// =====================
|
||||
|
||||
async loadUploadSettings() {
|
||||
try {
|
||||
this.uploadSettings = await api.getUploadSettings();
|
||||
this.renderUploadSettings();
|
||||
} catch (error) {
|
||||
console.error('Error loading upload settings:', error);
|
||||
}
|
||||
}
|
||||
|
||||
renderUploadSettings() {
|
||||
if (!this.uploadSettings) return;
|
||||
|
||||
// Maximale Dateigröße setzen
|
||||
if (this.uploadMaxSizeInput) {
|
||||
this.uploadMaxSizeInput.value = this.uploadSettings.maxFileSizeMB || 15;
|
||||
}
|
||||
|
||||
// Kategorien setzen
|
||||
const categoryMap = {
|
||||
'images': 'upload-cat-images',
|
||||
'documents': 'upload-cat-documents',
|
||||
'office': 'upload-cat-office',
|
||||
'text': 'upload-cat-text',
|
||||
'archives': 'upload-cat-archives',
|
||||
'data': 'upload-cat-data'
|
||||
};
|
||||
|
||||
Object.entries(categoryMap).forEach(([category, checkboxId]) => {
|
||||
const checkbox = $(`#${checkboxId}`);
|
||||
const categoryEl = $(`.upload-category[data-category="${category}"]`);
|
||||
|
||||
if (checkbox && this.uploadSettings.allowedTypes?.[category]) {
|
||||
const isEnabled = this.uploadSettings.allowedTypes[category].enabled;
|
||||
checkbox.checked = isEnabled;
|
||||
this.toggleUploadCategory(categoryEl, isEnabled);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
toggleUploadCategory(categoryEl, enabled) {
|
||||
if (!categoryEl) return;
|
||||
|
||||
if (enabled) {
|
||||
categoryEl.classList.remove('disabled');
|
||||
} else {
|
||||
categoryEl.classList.add('disabled');
|
||||
}
|
||||
}
|
||||
|
||||
async saveUploadSettings() {
|
||||
try {
|
||||
const maxFileSizeMB = parseInt(this.uploadMaxSizeInput?.value) || 15;
|
||||
|
||||
// Validierung
|
||||
if (maxFileSizeMB < 1 || maxFileSizeMB > 100) {
|
||||
this.showToast('Dateigröße muss zwischen 1 und 100 MB liegen', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Kategorien sammeln
|
||||
const allowedTypes = {
|
||||
images: {
|
||||
enabled: $('#upload-cat-images')?.checked ?? true,
|
||||
types: ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml']
|
||||
},
|
||||
documents: {
|
||||
enabled: $('#upload-cat-documents')?.checked ?? true,
|
||||
types: ['application/pdf']
|
||||
},
|
||||
office: {
|
||||
enabled: $('#upload-cat-office')?.checked ?? true,
|
||||
types: [
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'application/vnd.ms-powerpoint',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation'
|
||||
]
|
||||
},
|
||||
text: {
|
||||
enabled: $('#upload-cat-text')?.checked ?? true,
|
||||
types: ['text/plain', 'text/csv', 'text/markdown']
|
||||
},
|
||||
archives: {
|
||||
enabled: $('#upload-cat-archives')?.checked ?? true,
|
||||
types: ['application/zip', 'application/x-rar-compressed', 'application/x-7z-compressed']
|
||||
},
|
||||
data: {
|
||||
enabled: $('#upload-cat-data')?.checked ?? true,
|
||||
types: ['application/json']
|
||||
}
|
||||
};
|
||||
|
||||
// Prüfen ob mindestens eine Kategorie aktiviert ist
|
||||
const hasEnabledCategory = Object.values(allowedTypes).some(cat => cat.enabled);
|
||||
if (!hasEnabledCategory) {
|
||||
this.showToast('Mindestens eine Dateikategorie muss aktiviert sein', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
await api.updateUploadSettings({ maxFileSizeMB, allowedTypes });
|
||||
|
||||
this.uploadSettings = { maxFileSizeMB, allowedTypes };
|
||||
this.showToast('Upload-Einstellungen gespeichert', 'success');
|
||||
} catch (error) {
|
||||
console.error('Error saving upload settings:', error);
|
||||
this.showToast(error.message || 'Fehler beim Speichern', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
show() {
|
||||
this.adminScreen?.classList.add('active');
|
||||
}
|
||||
|
||||
hide() {
|
||||
this.adminScreen?.classList.remove('active');
|
||||
}
|
||||
}
|
||||
|
||||
// Create singleton instance
|
||||
const adminManager = new AdminManager();
|
||||
|
||||
export { adminManager };
|
||||
export default adminManager;
|
||||
862
frontend/js/api.js
Normale Datei
862
frontend/js/api.js
Normale Datei
@ -0,0 +1,862 @@
|
||||
/**
|
||||
* TASKMATE - API Client
|
||||
* =====================
|
||||
*/
|
||||
|
||||
class ApiClient {
|
||||
constructor() {
|
||||
this.baseUrl = '/api';
|
||||
this.token = null;
|
||||
this.csrfToken = null;
|
||||
this.refreshingToken = false;
|
||||
this.requestQueue = [];
|
||||
}
|
||||
|
||||
// Token Management
|
||||
setToken(token) {
|
||||
this.token = token;
|
||||
if (token) {
|
||||
localStorage.setItem('auth_token', token);
|
||||
} else {
|
||||
localStorage.removeItem('auth_token');
|
||||
}
|
||||
}
|
||||
|
||||
getToken() {
|
||||
if (!this.token) {
|
||||
this.token = localStorage.getItem('auth_token');
|
||||
}
|
||||
return this.token;
|
||||
}
|
||||
|
||||
setCsrfToken(token) {
|
||||
this.csrfToken = token;
|
||||
if (token) {
|
||||
sessionStorage.setItem('csrf_token', token);
|
||||
} else {
|
||||
sessionStorage.removeItem('csrf_token');
|
||||
}
|
||||
}
|
||||
|
||||
getCsrfToken() {
|
||||
if (!this.csrfToken) {
|
||||
this.csrfToken = sessionStorage.getItem('csrf_token');
|
||||
}
|
||||
return this.csrfToken;
|
||||
}
|
||||
|
||||
// Base Request Method
|
||||
async request(endpoint, options = {}) {
|
||||
const url = `${this.baseUrl}${endpoint}`;
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
};
|
||||
|
||||
// Add auth token
|
||||
const token = this.getToken();
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
// Add CSRF token for mutating requests
|
||||
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(options.method)) {
|
||||
const csrfToken = this.getCsrfToken();
|
||||
if (csrfToken) {
|
||||
headers['X-CSRF-Token'] = csrfToken;
|
||||
}
|
||||
}
|
||||
|
||||
const config = {
|
||||
method: options.method || 'GET',
|
||||
headers,
|
||||
credentials: 'same-origin'
|
||||
};
|
||||
|
||||
if (options.body && config.method !== 'GET') {
|
||||
config.body = JSON.stringify(options.body);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, config);
|
||||
|
||||
// Update CSRF token if provided
|
||||
const newCsrfToken = response.headers.get('X-CSRF-Token');
|
||||
if (newCsrfToken) {
|
||||
this.setCsrfToken(newCsrfToken);
|
||||
}
|
||||
|
||||
// Handle 401 Unauthorized
|
||||
if (response.status === 401) {
|
||||
this.setToken(null);
|
||||
window.dispatchEvent(new CustomEvent('auth:logout'));
|
||||
throw new ApiError('Sitzung abgelaufen', 401);
|
||||
}
|
||||
|
||||
// Handle CSRF errors - update token and retry once
|
||||
if (response.status === 403) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
if (errorData.code === 'CSRF_ERROR' && errorData.csrfToken) {
|
||||
// Store the new CSRF token
|
||||
this.setCsrfToken(errorData.csrfToken);
|
||||
|
||||
// Retry the request once with the new token
|
||||
if (!options._csrfRetried) {
|
||||
return this.request(endpoint, { ...options, _csrfRetried: true });
|
||||
}
|
||||
}
|
||||
throw new ApiError(
|
||||
errorData.error || 'Zugriff verweigert',
|
||||
response.status,
|
||||
errorData
|
||||
);
|
||||
}
|
||||
|
||||
// Handle other errors
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new ApiError(
|
||||
errorData.error || `HTTP Error ${response.status}`,
|
||||
response.status,
|
||||
errorData
|
||||
);
|
||||
}
|
||||
|
||||
// Parse response
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType && contentType.includes('application/json')) {
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Network error
|
||||
if (!navigator.onLine) {
|
||||
throw new ApiError('Keine Internetverbindung', 0, { offline: true });
|
||||
}
|
||||
|
||||
throw new ApiError('Netzwerkfehler: ' + error.message, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Convenience Methods
|
||||
get(endpoint, options = {}) {
|
||||
return this.request(endpoint, { ...options, method: 'GET' });
|
||||
}
|
||||
|
||||
post(endpoint, body, options = {}) {
|
||||
return this.request(endpoint, { ...options, method: 'POST', body });
|
||||
}
|
||||
|
||||
put(endpoint, body, options = {}) {
|
||||
return this.request(endpoint, { ...options, method: 'PUT', body });
|
||||
}
|
||||
|
||||
patch(endpoint, body, options = {}) {
|
||||
return this.request(endpoint, { ...options, method: 'PATCH', body });
|
||||
}
|
||||
|
||||
delete(endpoint, options = {}) {
|
||||
return this.request(endpoint, { ...options, method: 'DELETE' });
|
||||
}
|
||||
|
||||
// File Upload
|
||||
async uploadFile(endpoint, file, onProgress, _retried = false) {
|
||||
const url = `${this.baseUrl}${endpoint}`;
|
||||
const formData = new FormData();
|
||||
formData.append('files', file);
|
||||
|
||||
const token = this.getToken();
|
||||
const csrfToken = this.getCsrfToken();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
|
||||
xhr.open('POST', url);
|
||||
|
||||
if (token) {
|
||||
xhr.setRequestHeader('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
|
||||
if (csrfToken) {
|
||||
xhr.setRequestHeader('X-CSRF-Token', csrfToken);
|
||||
}
|
||||
|
||||
xhr.upload.addEventListener('progress', (e) => {
|
||||
if (e.lengthComputable && onProgress) {
|
||||
const percentage = Math.round((e.loaded / e.total) * 100);
|
||||
onProgress(percentage);
|
||||
}
|
||||
});
|
||||
|
||||
xhr.addEventListener('load', async () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
try {
|
||||
resolve(JSON.parse(xhr.responseText));
|
||||
} catch {
|
||||
resolve(xhr.responseText);
|
||||
}
|
||||
} else if (xhr.status === 403 && !_retried) {
|
||||
// Handle CSRF error - get new token and retry
|
||||
try {
|
||||
const errorData = JSON.parse(xhr.responseText);
|
||||
if (errorData.code === 'CSRF_ERROR' && errorData.csrfToken) {
|
||||
this.setCsrfToken(errorData.csrfToken);
|
||||
// Retry with new token
|
||||
try {
|
||||
const result = await this.uploadFile(endpoint, file, onProgress, true);
|
||||
resolve(result);
|
||||
} catch (retryError) {
|
||||
reject(retryError);
|
||||
}
|
||||
return;
|
||||
}
|
||||
} catch {}
|
||||
reject(new ApiError('Upload fehlgeschlagen', xhr.status));
|
||||
} else {
|
||||
try {
|
||||
const error = JSON.parse(xhr.responseText);
|
||||
reject(new ApiError(error.error || 'Upload fehlgeschlagen', xhr.status));
|
||||
} catch {
|
||||
reject(new ApiError('Upload fehlgeschlagen', xhr.status));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
xhr.addEventListener('error', () => {
|
||||
reject(new ApiError('Netzwerkfehler beim Upload', 0));
|
||||
});
|
||||
|
||||
xhr.send(formData);
|
||||
});
|
||||
}
|
||||
|
||||
// Download File
|
||||
async downloadFile(endpoint, filename) {
|
||||
const url = `${this.baseUrl}${endpoint}`;
|
||||
const token = this.getToken();
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: token ? { 'Authorization': `Bearer ${token}` } : {}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ApiError('Download fehlgeschlagen', response.status);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const downloadUrl = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = downloadUrl;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(downloadUrl);
|
||||
}
|
||||
|
||||
// =====================
|
||||
// AUTH ENDPOINTS
|
||||
// =====================
|
||||
|
||||
async login(username, password) {
|
||||
const response = await this.post('/auth/login', { username, password });
|
||||
this.setToken(response.token);
|
||||
// Store CSRF token from login response
|
||||
if (response.csrfToken) {
|
||||
this.setCsrfToken(response.csrfToken);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
async logout() {
|
||||
try {
|
||||
await this.post('/auth/logout', {});
|
||||
} finally {
|
||||
this.setToken(null);
|
||||
this.setCsrfToken(null);
|
||||
}
|
||||
}
|
||||
|
||||
async changePassword(currentPassword, newPassword) {
|
||||
return this.post('/auth/password', { currentPassword, newPassword });
|
||||
}
|
||||
|
||||
async updateUserColor(color) {
|
||||
return this.put('/auth/color', { color });
|
||||
}
|
||||
|
||||
async getUsers() {
|
||||
return this.get('/auth/users');
|
||||
}
|
||||
|
||||
// =====================
|
||||
// PROJECT ENDPOINTS
|
||||
// =====================
|
||||
|
||||
async getProjects() {
|
||||
return this.get('/projects');
|
||||
}
|
||||
|
||||
async getProject(id) {
|
||||
return this.get(`/projects/${id}`);
|
||||
}
|
||||
|
||||
async createProject(data) {
|
||||
return this.post('/projects', data);
|
||||
}
|
||||
|
||||
async updateProject(id, data) {
|
||||
return this.put(`/projects/${id}`, data);
|
||||
}
|
||||
|
||||
async deleteProject(id, force = true) {
|
||||
return this.delete(`/projects/${id}?force=${force}`);
|
||||
}
|
||||
|
||||
// =====================
|
||||
// COLUMN ENDPOINTS
|
||||
// =====================
|
||||
|
||||
async getColumns(projectId) {
|
||||
return this.get(`/columns/${projectId}`);
|
||||
}
|
||||
|
||||
async createColumn(projectId, data) {
|
||||
return this.post('/columns', { ...data, projectId });
|
||||
}
|
||||
|
||||
async updateColumn(projectId, columnId, data) {
|
||||
return this.put(`/columns/${columnId}`, data);
|
||||
}
|
||||
|
||||
async deleteColumn(projectId, columnId) {
|
||||
return this.delete(`/columns/${columnId}`);
|
||||
}
|
||||
|
||||
async reorderColumns(projectId, columnId, newPosition) {
|
||||
return this.put(`/columns/${columnId}/position`, { newPosition });
|
||||
}
|
||||
|
||||
// =====================
|
||||
// TASK ENDPOINTS
|
||||
// =====================
|
||||
|
||||
async getTasks(projectId, params = {}) {
|
||||
const queryString = new URLSearchParams(params).toString();
|
||||
const endpoint = `/tasks/project/${projectId}${queryString ? '?' + queryString : ''}`;
|
||||
return this.get(endpoint);
|
||||
}
|
||||
|
||||
async getTask(projectId, taskId) {
|
||||
return this.get(`/tasks/${taskId}`);
|
||||
}
|
||||
|
||||
async createTask(projectId, data) {
|
||||
return this.post('/tasks', { ...data, projectId });
|
||||
}
|
||||
|
||||
async updateTask(projectId, taskId, data) {
|
||||
return this.put(`/tasks/${taskId}`, data);
|
||||
}
|
||||
|
||||
async deleteTask(projectId, taskId) {
|
||||
return this.delete(`/tasks/${taskId}`);
|
||||
}
|
||||
|
||||
async moveTask(projectId, taskId, columnId, position) {
|
||||
return this.put(`/tasks/${taskId}/move`, { columnId, position });
|
||||
}
|
||||
|
||||
async duplicateTask(projectId, taskId) {
|
||||
return this.post(`/tasks/${taskId}/duplicate`, {});
|
||||
}
|
||||
|
||||
async archiveTask(projectId, taskId) {
|
||||
return this.put(`/tasks/${taskId}/archive`, { archived: true });
|
||||
}
|
||||
|
||||
async restoreTask(projectId, taskId) {
|
||||
return this.put(`/tasks/${taskId}/archive`, { archived: false });
|
||||
}
|
||||
|
||||
async getTaskHistory(projectId, taskId) {
|
||||
return this.get(`/tasks/${taskId}/history`);
|
||||
}
|
||||
|
||||
async searchTasks(projectId, query) {
|
||||
return this.get(`/tasks/search?projectId=${projectId}&q=${encodeURIComponent(query)}`);
|
||||
}
|
||||
|
||||
// =====================
|
||||
// SUBTASK ENDPOINTS
|
||||
// =====================
|
||||
|
||||
async getSubtasks(projectId, taskId) {
|
||||
return this.get(`/subtasks/${taskId}`);
|
||||
}
|
||||
|
||||
async createSubtask(projectId, taskId, data) {
|
||||
return this.post('/subtasks', { ...data, taskId });
|
||||
}
|
||||
|
||||
async updateSubtask(projectId, taskId, subtaskId, data) {
|
||||
return this.put(`/subtasks/${subtaskId}`, data);
|
||||
}
|
||||
|
||||
async deleteSubtask(projectId, taskId, subtaskId) {
|
||||
return this.delete(`/subtasks/${subtaskId}`);
|
||||
}
|
||||
|
||||
async reorderSubtasks(projectId, taskId, subtaskId, newPosition) {
|
||||
return this.put(`/subtasks/${subtaskId}/position`, { newPosition });
|
||||
}
|
||||
|
||||
// =====================
|
||||
// COMMENT ENDPOINTS
|
||||
// =====================
|
||||
|
||||
async getComments(projectId, taskId) {
|
||||
return this.get(`/comments/${taskId}`);
|
||||
}
|
||||
|
||||
async createComment(projectId, taskId, data) {
|
||||
return this.post('/comments', { ...data, taskId });
|
||||
}
|
||||
|
||||
async updateComment(projectId, taskId, commentId, data) {
|
||||
return this.put(`/comments/${commentId}`, data);
|
||||
}
|
||||
|
||||
async deleteComment(projectId, taskId, commentId) {
|
||||
return this.delete(`/comments/${commentId}`);
|
||||
}
|
||||
|
||||
// =====================
|
||||
// LABEL ENDPOINTS
|
||||
// =====================
|
||||
|
||||
async getLabels(projectId) {
|
||||
return this.get(`/labels/${projectId}`);
|
||||
}
|
||||
|
||||
async createLabel(projectId, data) {
|
||||
return this.post('/labels', { ...data, projectId });
|
||||
}
|
||||
|
||||
async updateLabel(projectId, labelId, data) {
|
||||
return this.put(`/labels/${labelId}`, data);
|
||||
}
|
||||
|
||||
async deleteLabel(projectId, labelId) {
|
||||
return this.delete(`/labels/${labelId}`);
|
||||
}
|
||||
|
||||
// =====================
|
||||
// FILE ENDPOINTS
|
||||
// =====================
|
||||
|
||||
async getFiles(projectId, taskId) {
|
||||
return this.get(`/files/${taskId}`);
|
||||
}
|
||||
|
||||
async uploadTaskFile(projectId, taskId, file, onProgress) {
|
||||
return this.uploadFile(`/files/${taskId}`, file, onProgress);
|
||||
}
|
||||
|
||||
async downloadTaskFile(projectId, taskId, fileId, filename) {
|
||||
return this.downloadFile(`/files/download/${fileId}`, filename);
|
||||
}
|
||||
|
||||
async deleteFile(projectId, taskId, fileId) {
|
||||
return this.delete(`/files/${fileId}`);
|
||||
}
|
||||
|
||||
getFilePreviewUrl(projectId, taskId, fileId) {
|
||||
const token = this.getToken();
|
||||
const url = `${this.baseUrl}/files/preview/${fileId}`;
|
||||
// Add token as query parameter for img src authentication
|
||||
return token ? `${url}?token=${encodeURIComponent(token)}` : url;
|
||||
}
|
||||
|
||||
// =====================
|
||||
// LINK ENDPOINTS
|
||||
// =====================
|
||||
|
||||
async getLinks(projectId, taskId) {
|
||||
return this.get(`/links/${taskId}`);
|
||||
}
|
||||
|
||||
async createLink(projectId, taskId, data) {
|
||||
return this.post('/links', { ...data, taskId });
|
||||
}
|
||||
|
||||
async updateLink(projectId, taskId, linkId, data) {
|
||||
return this.put(`/links/${linkId}`, data);
|
||||
}
|
||||
|
||||
async deleteLink(projectId, taskId, linkId) {
|
||||
return this.delete(`/links/${linkId}`);
|
||||
}
|
||||
|
||||
// =====================
|
||||
// TEMPLATE ENDPOINTS
|
||||
// =====================
|
||||
|
||||
async getTemplates() {
|
||||
return this.get('/templates');
|
||||
}
|
||||
|
||||
async getTemplate(id) {
|
||||
return this.get(`/templates/${id}`);
|
||||
}
|
||||
|
||||
async createTemplate(data) {
|
||||
return this.post('/templates', data);
|
||||
}
|
||||
|
||||
async updateTemplate(id, data) {
|
||||
return this.put(`/templates/${id}`, data);
|
||||
}
|
||||
|
||||
async deleteTemplate(id) {
|
||||
return this.delete(`/templates/${id}`);
|
||||
}
|
||||
|
||||
async createTaskFromTemplate(projectId, templateId, columnId) {
|
||||
return this.post(`/templates/${templateId}/apply`, { projectId, columnId });
|
||||
}
|
||||
|
||||
// =====================
|
||||
// STATS ENDPOINTS
|
||||
// =====================
|
||||
|
||||
async getStats(projectId) {
|
||||
const endpoint = projectId ? `/stats/dashboard?projectId=${projectId}` : '/stats/dashboard';
|
||||
return this.get(endpoint);
|
||||
}
|
||||
|
||||
async getCompletionStats(projectId, weeks = 8) {
|
||||
const endpoint = projectId
|
||||
? `/stats/completed-per-week?projectId=${projectId}&weeks=${weeks}`
|
||||
: `/stats/completed-per-week?weeks=${weeks}`;
|
||||
return this.get(endpoint);
|
||||
}
|
||||
|
||||
async getTimeStats(projectId) {
|
||||
return this.get('/stats/time-per-project');
|
||||
}
|
||||
|
||||
async getOverdueTasks(projectId) {
|
||||
// Placeholder - overdue data comes from dashboard stats
|
||||
return [];
|
||||
}
|
||||
|
||||
async getDueTodayTasks(projectId) {
|
||||
// Placeholder - due today data comes from dashboard stats
|
||||
return [];
|
||||
}
|
||||
|
||||
// =====================
|
||||
// EXPORT/IMPORT ENDPOINTS
|
||||
// =====================
|
||||
|
||||
async exportProject(projectId, format = 'json') {
|
||||
const response = await this.get(`/export/project/${projectId}?format=${format}`);
|
||||
|
||||
if (format === 'csv') {
|
||||
// For CSV, trigger download
|
||||
const blob = new Blob([response], { type: 'text/csv' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `project_${projectId}_export.csv`;
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
async exportAll(format = 'json') {
|
||||
return this.get(`/export/all?format=${format}`);
|
||||
}
|
||||
|
||||
async importProject(data) {
|
||||
return this.post('/import', data);
|
||||
}
|
||||
|
||||
// =====================
|
||||
// HEALTH ENDPOINTS
|
||||
// =====================
|
||||
|
||||
async healthCheck() {
|
||||
return this.get('/health');
|
||||
}
|
||||
|
||||
async getSystemInfo() {
|
||||
return this.get('/health/info');
|
||||
}
|
||||
|
||||
// =====================
|
||||
// ADMIN ENDPOINTS
|
||||
// =====================
|
||||
|
||||
async getAdminUsers() {
|
||||
return this.get('/admin/users');
|
||||
}
|
||||
|
||||
async createAdminUser(data) {
|
||||
return this.post('/admin/users', data);
|
||||
}
|
||||
|
||||
async updateAdminUser(userId, data) {
|
||||
return this.put(`/admin/users/${userId}`, data);
|
||||
}
|
||||
|
||||
async deleteAdminUser(userId) {
|
||||
return this.delete(`/admin/users/${userId}`);
|
||||
}
|
||||
|
||||
async getUploadSettings() {
|
||||
return this.get('/admin/upload-settings');
|
||||
}
|
||||
|
||||
async updateUploadSettings(settings) {
|
||||
return this.put('/admin/upload-settings', settings);
|
||||
}
|
||||
|
||||
// =====================
|
||||
// PROPOSALS (GENEHMIGUNGEN) ENDPOINTS
|
||||
// =====================
|
||||
|
||||
async getProposals(sort = 'date', archived = false, projectId = null) {
|
||||
let url = `/proposals?sort=${sort}&archived=${archived ? '1' : '0'}`;
|
||||
if (projectId) {
|
||||
url += `&projectId=${projectId}`;
|
||||
}
|
||||
return this.get(url);
|
||||
}
|
||||
|
||||
async createProposal(data) {
|
||||
return this.post('/proposals', data);
|
||||
}
|
||||
|
||||
async approveProposal(proposalId, approved) {
|
||||
return this.put(`/proposals/${proposalId}/approve`, { approved });
|
||||
}
|
||||
|
||||
async archiveProposal(proposalId, archived) {
|
||||
return this.put(`/proposals/${proposalId}/archive`, { archived });
|
||||
}
|
||||
|
||||
async deleteProposal(proposalId) {
|
||||
return this.delete(`/proposals/${proposalId}`);
|
||||
}
|
||||
|
||||
// =====================
|
||||
// TASKS FOR PROPOSALS
|
||||
// =====================
|
||||
|
||||
async getAllTasks() {
|
||||
return this.get('/tasks/all');
|
||||
}
|
||||
|
||||
// =====================
|
||||
// NOTIFICATION ENDPOINTS
|
||||
// =====================
|
||||
|
||||
async getNotifications() {
|
||||
return this.get('/notifications');
|
||||
}
|
||||
|
||||
async getNotificationCount() {
|
||||
return this.get('/notifications/count');
|
||||
}
|
||||
|
||||
async markNotificationRead(id) {
|
||||
return this.put(`/notifications/${id}/read`, {});
|
||||
}
|
||||
|
||||
async markAllNotificationsRead() {
|
||||
return this.put('/notifications/read-all', {});
|
||||
}
|
||||
|
||||
async deleteNotification(id) {
|
||||
return this.delete(`/notifications/${id}`);
|
||||
}
|
||||
|
||||
// =====================
|
||||
// GITEA ENDPOINTS
|
||||
// =====================
|
||||
|
||||
async testGiteaConnection() {
|
||||
return this.get('/gitea/test');
|
||||
}
|
||||
|
||||
async getGiteaRepositories(page = 1, limit = 50) {
|
||||
return this.get(`/gitea/repositories?page=${page}&limit=${limit}`);
|
||||
}
|
||||
|
||||
async createGiteaRepository(data) {
|
||||
return this.post('/gitea/repositories', data);
|
||||
}
|
||||
|
||||
async getGiteaRepository(owner, repo) {
|
||||
return this.get(`/gitea/repositories/${owner}/${repo}`);
|
||||
}
|
||||
|
||||
async deleteGiteaRepository(owner, repo) {
|
||||
return this.delete(`/gitea/repositories/${owner}/${repo}`);
|
||||
}
|
||||
|
||||
async getGiteaBranches(owner, repo) {
|
||||
return this.get(`/gitea/repositories/${owner}/${repo}/branches`);
|
||||
}
|
||||
|
||||
async getGiteaCommits(owner, repo, options = {}) {
|
||||
const params = new URLSearchParams();
|
||||
if (options.page) params.append('page', options.page);
|
||||
if (options.limit) params.append('limit', options.limit);
|
||||
if (options.branch) params.append('branch', options.branch);
|
||||
const queryString = params.toString();
|
||||
return this.get(`/gitea/repositories/${owner}/${repo}/commits${queryString ? '?' + queryString : ''}`);
|
||||
}
|
||||
|
||||
async getGiteaUser() {
|
||||
return this.get('/gitea/user');
|
||||
}
|
||||
|
||||
// =====================
|
||||
// APPLICATIONS (Projekt-Repository-Verknüpfung) ENDPOINTS
|
||||
// =====================
|
||||
|
||||
async getProjectApplication(projectId) {
|
||||
return this.get(`/applications/${projectId}`);
|
||||
}
|
||||
|
||||
async saveProjectApplication(data) {
|
||||
return this.post('/applications', data);
|
||||
}
|
||||
|
||||
async deleteProjectApplication(projectId) {
|
||||
return this.delete(`/applications/${projectId}`);
|
||||
}
|
||||
|
||||
async getUserBasePath() {
|
||||
return this.get('/applications/user/base-path');
|
||||
}
|
||||
|
||||
async setUserBasePath(basePath) {
|
||||
return this.put('/applications/user/base-path', { basePath });
|
||||
}
|
||||
|
||||
async syncProjectApplication(projectId) {
|
||||
return this.post(`/applications/${projectId}/sync`, {});
|
||||
}
|
||||
|
||||
// =====================
|
||||
// GIT OPERATIONS ENDPOINTS
|
||||
// =====================
|
||||
|
||||
async cloneRepository(data) {
|
||||
return this.post('/git/clone', data);
|
||||
}
|
||||
|
||||
async getGitStatus(projectId) {
|
||||
return this.get(`/git/status/${projectId}`);
|
||||
}
|
||||
|
||||
async gitPull(projectId, branch = null) {
|
||||
return this.post(`/git/pull/${projectId}`, { branch });
|
||||
}
|
||||
|
||||
async gitPush(projectId, branch = null) {
|
||||
return this.post(`/git/push/${projectId}`, { branch });
|
||||
}
|
||||
|
||||
async gitCommit(projectId, message, stageAll = true) {
|
||||
return this.post(`/git/commit/${projectId}`, { message, stageAll });
|
||||
}
|
||||
|
||||
async getGitCommits(projectId, limit = 20) {
|
||||
return this.get(`/git/commits/${projectId}?limit=${limit}`);
|
||||
}
|
||||
|
||||
async getGitBranches(projectId) {
|
||||
return this.get(`/git/branches/${projectId}`);
|
||||
}
|
||||
|
||||
async gitCheckout(projectId, branch) {
|
||||
return this.post(`/git/checkout/${projectId}`, { branch });
|
||||
}
|
||||
|
||||
async gitFetch(projectId) {
|
||||
return this.post(`/git/fetch/${projectId}`, {});
|
||||
}
|
||||
|
||||
async gitStage(projectId) {
|
||||
return this.post(`/git/stage/${projectId}`, {});
|
||||
}
|
||||
|
||||
async getGitRemote(projectId) {
|
||||
return this.get(`/git/remote/${projectId}`);
|
||||
}
|
||||
|
||||
async validatePath(path) {
|
||||
return this.post('/git/validate-path', { path });
|
||||
}
|
||||
|
||||
async prepareRepository(projectId, repoUrl, branch = 'main') {
|
||||
return this.post(`/git/prepare/${projectId}`, { repoUrl, branch });
|
||||
}
|
||||
|
||||
async setGitRemote(projectId, repoUrl) {
|
||||
return this.post(`/git/set-remote/${projectId}`, { repoUrl });
|
||||
}
|
||||
|
||||
async gitInitPush(projectId, branch = 'main') {
|
||||
return this.post(`/git/init-push/${projectId}`, { branch });
|
||||
}
|
||||
}
|
||||
|
||||
// Custom API Error Class
|
||||
class ApiError extends Error {
|
||||
constructor(message, status, data = {}) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
this.status = status;
|
||||
this.data = data;
|
||||
}
|
||||
|
||||
get isOffline() {
|
||||
return this.data.offline === true;
|
||||
}
|
||||
|
||||
get isUnauthorized() {
|
||||
return this.status === 401;
|
||||
}
|
||||
|
||||
get isNotFound() {
|
||||
return this.status === 404;
|
||||
}
|
||||
|
||||
get isValidationError() {
|
||||
return this.status === 400;
|
||||
}
|
||||
|
||||
get isServerError() {
|
||||
return this.status >= 500;
|
||||
}
|
||||
}
|
||||
|
||||
// Create singleton instance
|
||||
const api = new ApiClient();
|
||||
|
||||
export { api, ApiError };
|
||||
export default api;
|
||||
1474
frontend/js/app.js
Normale Datei
1474
frontend/js/app.js
Normale Datei
Datei-Diff unterdrückt, da er zu groß ist
Diff laden
546
frontend/js/auth.js
Normale Datei
546
frontend/js/auth.js
Normale Datei
@ -0,0 +1,546 @@
|
||||
/**
|
||||
* TASKMATE - Authentication Module
|
||||
* ================================
|
||||
*/
|
||||
|
||||
import api from './api.js';
|
||||
import { $, $$ } from './utils.js';
|
||||
|
||||
class AuthManager {
|
||||
constructor() {
|
||||
this.user = null;
|
||||
this.isAuthenticated = false;
|
||||
this.loginAttempts = 0;
|
||||
this.maxLoginAttempts = 5;
|
||||
this.lockoutDuration = 5 * 60 * 1000; // 5 minutes
|
||||
this.lockoutUntil = null;
|
||||
}
|
||||
|
||||
// Initialize authentication state
|
||||
async init() {
|
||||
const token = api.getToken();
|
||||
|
||||
if (token) {
|
||||
try {
|
||||
// Verify token by making a request
|
||||
const users = await api.getUsers();
|
||||
this.isAuthenticated = true;
|
||||
|
||||
// Get current user from stored data
|
||||
const storedUser = localStorage.getItem('current_user');
|
||||
if (storedUser) {
|
||||
this.user = JSON.parse(storedUser);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
// Token invalid
|
||||
this.logout();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Login
|
||||
async login(username, password) {
|
||||
// Check lockout
|
||||
if (this.isLockedOut()) {
|
||||
const remainingTime = Math.ceil((this.lockoutUntil - Date.now()) / 1000);
|
||||
throw new Error(`Zu viele Fehlversuche. Bitte warten Sie ${remainingTime} Sekunden.`);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await api.login(username, password);
|
||||
|
||||
this.user = response.user;
|
||||
this.isAuthenticated = true;
|
||||
this.loginAttempts = 0;
|
||||
|
||||
// Store user data
|
||||
localStorage.setItem('current_user', JSON.stringify(response.user));
|
||||
|
||||
// Dispatch login event
|
||||
window.dispatchEvent(new CustomEvent('auth:login', {
|
||||
detail: { user: this.user }
|
||||
}));
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
this.loginAttempts++;
|
||||
|
||||
if (this.loginAttempts >= this.maxLoginAttempts) {
|
||||
this.lockoutUntil = Date.now() + this.lockoutDuration;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Logout
|
||||
async logout() {
|
||||
try {
|
||||
if (this.isAuthenticated) {
|
||||
await api.logout();
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore logout errors
|
||||
} finally {
|
||||
this.user = null;
|
||||
this.isAuthenticated = false;
|
||||
|
||||
// Clear stored data
|
||||
localStorage.removeItem('current_user');
|
||||
localStorage.removeItem('auth_token');
|
||||
|
||||
// Dispatch logout event
|
||||
window.dispatchEvent(new CustomEvent('auth:logout'));
|
||||
}
|
||||
}
|
||||
|
||||
// Change Password
|
||||
async changePassword(currentPassword, newPassword) {
|
||||
return api.changePassword(currentPassword, newPassword);
|
||||
}
|
||||
|
||||
// Get Current User
|
||||
getUser() {
|
||||
return this.user;
|
||||
}
|
||||
|
||||
// Check if logged in
|
||||
isLoggedIn() {
|
||||
return this.isAuthenticated && this.user !== null;
|
||||
}
|
||||
|
||||
// Check lockout status
|
||||
isLockedOut() {
|
||||
if (!this.lockoutUntil) return false;
|
||||
|
||||
if (Date.now() >= this.lockoutUntil) {
|
||||
this.lockoutUntil = null;
|
||||
this.loginAttempts = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Get user initials
|
||||
getUserInitials() {
|
||||
if (!this.user) return '?';
|
||||
|
||||
return this.user.username
|
||||
.split(' ')
|
||||
.map(part => part.charAt(0).toUpperCase())
|
||||
.slice(0, 2)
|
||||
.join('');
|
||||
}
|
||||
|
||||
// Get user color
|
||||
getUserColor() {
|
||||
if (!this.user) return '#888888';
|
||||
return this.user.color || '#00D4FF';
|
||||
}
|
||||
|
||||
// Update user color
|
||||
updateUserColor(color) {
|
||||
if (this.user) {
|
||||
this.user.color = color;
|
||||
// Also update localStorage so color persists after refresh
|
||||
localStorage.setItem('current_user', JSON.stringify(this.user));
|
||||
}
|
||||
}
|
||||
|
||||
// Check if user is admin
|
||||
isAdmin() {
|
||||
return this.user?.role === 'admin';
|
||||
}
|
||||
|
||||
// Check if user has a specific permission
|
||||
hasPermission(permission) {
|
||||
if (!this.user) return false;
|
||||
const permissions = this.user.permissions || [];
|
||||
return permissions.includes(permission);
|
||||
}
|
||||
|
||||
// Get user role
|
||||
getRole() {
|
||||
return this.user?.role || 'user';
|
||||
}
|
||||
|
||||
// Get user permissions
|
||||
getPermissions() {
|
||||
return this.user?.permissions || [];
|
||||
}
|
||||
}
|
||||
|
||||
// Login Form Handler
|
||||
class LoginFormHandler {
|
||||
constructor(authManager) {
|
||||
this.auth = authManager;
|
||||
this.form = $('#login-form');
|
||||
this.usernameInput = $('#login-username');
|
||||
this.passwordInput = $('#login-password');
|
||||
this.submitButton = this.form?.querySelector('button[type="submit"]');
|
||||
this.errorMessage = $('#login-error');
|
||||
|
||||
this.bindEvents();
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
if (!this.form) return;
|
||||
|
||||
this.form.addEventListener('submit', (e) => this.handleSubmit(e));
|
||||
|
||||
// Enter key handling
|
||||
this.passwordInput?.addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
this.form.dispatchEvent(new Event('submit'));
|
||||
}
|
||||
});
|
||||
|
||||
// Clear error on input
|
||||
[this.usernameInput, this.passwordInput].forEach(input => {
|
||||
input?.addEventListener('input', () => this.clearError());
|
||||
});
|
||||
}
|
||||
|
||||
async handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const username = this.usernameInput?.value.trim();
|
||||
const password = this.passwordInput?.value;
|
||||
|
||||
if (!username || !password) {
|
||||
this.showError('Bitte Benutzername und Passwort eingeben.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.setLoading(true);
|
||||
this.clearError();
|
||||
|
||||
try {
|
||||
await this.auth.login(username, password);
|
||||
// Success - app will handle the redirect
|
||||
} catch (error) {
|
||||
this.showError(error.message || 'Anmeldung fehlgeschlagen.');
|
||||
this.passwordInput.value = '';
|
||||
this.passwordInput.focus();
|
||||
} finally {
|
||||
this.setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
showError(message) {
|
||||
if (this.errorMessage) {
|
||||
this.errorMessage.textContent = message;
|
||||
this.errorMessage.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
clearError() {
|
||||
if (this.errorMessage) {
|
||||
this.errorMessage.textContent = '';
|
||||
this.errorMessage.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
setLoading(loading) {
|
||||
if (this.submitButton) {
|
||||
this.submitButton.disabled = loading;
|
||||
this.submitButton.classList.toggle('loading', loading);
|
||||
}
|
||||
|
||||
if (this.usernameInput) this.usernameInput.disabled = loading;
|
||||
if (this.passwordInput) this.passwordInput.disabled = loading;
|
||||
}
|
||||
|
||||
reset() {
|
||||
if (this.form) this.form.reset();
|
||||
this.clearError();
|
||||
this.setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// User Menu Handler
|
||||
class UserMenuHandler {
|
||||
constructor(authManager) {
|
||||
this.auth = authManager;
|
||||
this.userMenu = $('.user-menu');
|
||||
this.userAvatar = $('#user-avatar');
|
||||
this.userDropdown = $('.user-dropdown');
|
||||
this.userName = $('#user-name');
|
||||
this.userRole = $('#user-role');
|
||||
this.logoutButton = $('#btn-logout');
|
||||
this.changePasswordButton = $('#btn-change-password');
|
||||
|
||||
this.isOpen = false;
|
||||
|
||||
this.bindEvents();
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
// Toggle dropdown
|
||||
this.userAvatar?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.toggle();
|
||||
});
|
||||
|
||||
// Close on outside click
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!this.userMenu?.contains(e.target)) {
|
||||
this.close();
|
||||
}
|
||||
});
|
||||
|
||||
// Logout
|
||||
this.logoutButton?.addEventListener('click', () => this.handleLogout());
|
||||
|
||||
// Change password
|
||||
this.changePasswordButton?.addEventListener('click', () => {
|
||||
this.close();
|
||||
window.dispatchEvent(new CustomEvent('modal:open', {
|
||||
detail: { modalId: 'change-password-modal' }
|
||||
}));
|
||||
});
|
||||
|
||||
// Escape key
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && this.isOpen) {
|
||||
this.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
update() {
|
||||
const user = this.auth.getUser();
|
||||
|
||||
if (this.userAvatar) {
|
||||
this.userAvatar.textContent = this.auth.getUserInitials();
|
||||
this.userAvatar.style.backgroundColor = this.auth.getUserColor();
|
||||
}
|
||||
|
||||
if (this.userName) {
|
||||
this.userName.textContent = user?.displayName || user?.username || 'Benutzer';
|
||||
}
|
||||
|
||||
if (this.userRole) {
|
||||
let roleText = 'Benutzer';
|
||||
if (user?.role === 'admin') {
|
||||
roleText = 'Administrator';
|
||||
} else if (user?.permissions?.length > 0) {
|
||||
roleText = user.permissions.map(p => p.charAt(0).toUpperCase() + p.slice(1)).join(', ');
|
||||
}
|
||||
this.userRole.textContent = roleText;
|
||||
}
|
||||
}
|
||||
|
||||
toggle() {
|
||||
if (this.isOpen) {
|
||||
this.close();
|
||||
} else {
|
||||
this.open();
|
||||
}
|
||||
}
|
||||
|
||||
open() {
|
||||
if (this.userDropdown) {
|
||||
this.userDropdown.classList.remove('hidden');
|
||||
this.isOpen = true;
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
if (this.userDropdown) {
|
||||
this.userDropdown.classList.add('hidden');
|
||||
this.isOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
async handleLogout() {
|
||||
this.close();
|
||||
|
||||
try {
|
||||
await this.auth.logout();
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Change Password Modal Handler
|
||||
class ChangePasswordHandler {
|
||||
constructor(authManager) {
|
||||
this.auth = authManager;
|
||||
this.modal = $('#change-password-modal');
|
||||
this.form = $('#change-password-form');
|
||||
this.currentPassword = $('#current-password');
|
||||
this.newPassword = $('#new-password');
|
||||
this.confirmPassword = $('#confirm-password');
|
||||
this.errorMessage = this.modal?.querySelector('.error-message');
|
||||
this.submitButton = this.form?.querySelector('button[type="submit"]');
|
||||
|
||||
this.bindEvents();
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
this.form?.addEventListener('submit', (e) => this.handleSubmit(e));
|
||||
|
||||
// Password strength indicator
|
||||
this.newPassword?.addEventListener('input', () => {
|
||||
this.updatePasswordStrength();
|
||||
});
|
||||
|
||||
// Confirm password validation
|
||||
this.confirmPassword?.addEventListener('input', () => {
|
||||
this.validateConfirmPassword();
|
||||
});
|
||||
}
|
||||
|
||||
async handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const currentPassword = this.currentPassword?.value;
|
||||
const newPassword = this.newPassword?.value;
|
||||
const confirmPassword = this.confirmPassword?.value;
|
||||
|
||||
// Validation
|
||||
if (!currentPassword || !newPassword || !confirmPassword) {
|
||||
this.showError('Bitte alle Felder ausfüllen.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword.length < 8) {
|
||||
this.showError('Das neue Passwort muss mindestens 8 Zeichen lang sein.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
this.showError('Die Passwörter stimmen nicht überein.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.setLoading(true);
|
||||
this.clearError();
|
||||
|
||||
try {
|
||||
await this.auth.changePassword(currentPassword, newPassword);
|
||||
|
||||
// Success
|
||||
this.reset();
|
||||
this.closeModal();
|
||||
|
||||
window.dispatchEvent(new CustomEvent('toast:show', {
|
||||
detail: {
|
||||
message: 'Passwort erfolgreich geändert.',
|
||||
type: 'success'
|
||||
}
|
||||
}));
|
||||
} catch (error) {
|
||||
this.showError(error.message || 'Fehler beim Ändern des Passworts.');
|
||||
} finally {
|
||||
this.setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
updatePasswordStrength() {
|
||||
const password = this.newPassword?.value || '';
|
||||
const strengthIndicator = this.modal?.querySelector('.password-strength');
|
||||
|
||||
if (!strengthIndicator) return;
|
||||
|
||||
let strength = 0;
|
||||
if (password.length >= 8) strength++;
|
||||
if (password.length >= 12) strength++;
|
||||
if (/[A-Z]/.test(password)) strength++;
|
||||
if (/[a-z]/.test(password)) strength++;
|
||||
if (/[0-9]/.test(password)) strength++;
|
||||
if (/[^A-Za-z0-9]/.test(password)) strength++;
|
||||
|
||||
const levels = ['weak', 'fair', 'good', 'strong'];
|
||||
const level = Math.min(Math.floor(strength / 1.5), 3);
|
||||
|
||||
strengthIndicator.className = `password-strength ${levels[level]}`;
|
||||
strengthIndicator.dataset.strength = levels[level];
|
||||
}
|
||||
|
||||
validateConfirmPassword() {
|
||||
const newPassword = this.newPassword?.value;
|
||||
const confirmPassword = this.confirmPassword?.value;
|
||||
|
||||
if (confirmPassword && newPassword !== confirmPassword) {
|
||||
this.confirmPassword.setCustomValidity('Passwörter stimmen nicht überein');
|
||||
} else {
|
||||
this.confirmPassword.setCustomValidity('');
|
||||
}
|
||||
}
|
||||
|
||||
showError(message) {
|
||||
if (this.errorMessage) {
|
||||
this.errorMessage.textContent = message;
|
||||
this.errorMessage.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
clearError() {
|
||||
if (this.errorMessage) {
|
||||
this.errorMessage.textContent = '';
|
||||
this.errorMessage.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
setLoading(loading) {
|
||||
if (this.submitButton) {
|
||||
this.submitButton.disabled = loading;
|
||||
this.submitButton.classList.toggle('loading', loading);
|
||||
}
|
||||
}
|
||||
|
||||
reset() {
|
||||
if (this.form) this.form.reset();
|
||||
this.clearError();
|
||||
this.setLoading(false);
|
||||
}
|
||||
|
||||
closeModal() {
|
||||
window.dispatchEvent(new CustomEvent('modal:close', {
|
||||
detail: { modalId: 'change-password-modal' }
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// Create singleton instances
|
||||
const authManager = new AuthManager();
|
||||
let loginFormHandler = null;
|
||||
let userMenuHandler = null;
|
||||
let changePasswordHandler = null;
|
||||
|
||||
// Initialize handlers when DOM is ready
|
||||
function initAuthHandlers() {
|
||||
loginFormHandler = new LoginFormHandler(authManager);
|
||||
userMenuHandler = new UserMenuHandler(authManager);
|
||||
changePasswordHandler = new ChangePasswordHandler(authManager);
|
||||
}
|
||||
|
||||
// Listen for DOM ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initAuthHandlers);
|
||||
} else {
|
||||
initAuthHandlers();
|
||||
}
|
||||
|
||||
// Listen for login event to update UI
|
||||
window.addEventListener('auth:login', () => {
|
||||
userMenuHandler?.update();
|
||||
});
|
||||
|
||||
export {
|
||||
authManager,
|
||||
loginFormHandler,
|
||||
userMenuHandler,
|
||||
changePasswordHandler
|
||||
};
|
||||
|
||||
export default authManager;
|
||||
1363
frontend/js/board.js
Normale Datei
1363
frontend/js/board.js
Normale Datei
Datei-Diff unterdrückt, da er zu groß ist
Diff laden
1006
frontend/js/calendar.js
Normale Datei
1006
frontend/js/calendar.js
Normale Datei
Datei-Diff unterdrückt, da er zu groß ist
Diff laden
335
frontend/js/dashboard.js
Normale Datei
335
frontend/js/dashboard.js
Normale Datei
@ -0,0 +1,335 @@
|
||||
/**
|
||||
* TASKMATE - Dashboard Module
|
||||
* ===========================
|
||||
* Statistics and overview dashboard
|
||||
*/
|
||||
|
||||
import store from './store.js';
|
||||
import api from './api.js';
|
||||
import {
|
||||
$, $$, createElement, clearElement, formatDate, getDueDateStatus,
|
||||
getInitials
|
||||
} from './utils.js';
|
||||
|
||||
class DashboardManager {
|
||||
constructor() {
|
||||
this.container = null;
|
||||
this.stats = null;
|
||||
this.completionData = null;
|
||||
this.timeData = null;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.container = $('#view-dashboard');
|
||||
|
||||
// Subscribe to store changes
|
||||
store.subscribe('currentView', (view) => {
|
||||
if (view === 'dashboard') this.loadAndRender();
|
||||
});
|
||||
|
||||
store.subscribe('currentProjectId', () => {
|
||||
if (store.get('currentView') === 'dashboard') {
|
||||
this.loadAndRender();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// =====================
|
||||
// DATA LOADING
|
||||
// =====================
|
||||
|
||||
async loadAndRender() {
|
||||
if (store.get('currentView') !== 'dashboard') return;
|
||||
|
||||
store.setLoading(true);
|
||||
|
||||
try {
|
||||
const projectId = store.get('currentProjectId');
|
||||
|
||||
const [stats, completionData, timeData] = await Promise.all([
|
||||
api.getStats(projectId),
|
||||
api.getCompletionStats(projectId, 8),
|
||||
api.getTimeStats(projectId)
|
||||
]);
|
||||
|
||||
this.stats = stats;
|
||||
this.completionData = completionData;
|
||||
this.timeData = timeData;
|
||||
// Due today tasks come from dashboard stats
|
||||
this.dueTodayTasks = stats.dueToday || [];
|
||||
// Overdue list - we only have the count, not individual tasks
|
||||
this.overdueTasks = [];
|
||||
|
||||
this.render();
|
||||
} catch (error) {
|
||||
console.error('Failed to load dashboard data:', error);
|
||||
this.showError('Fehler beim Laden der Dashboard-Daten');
|
||||
} finally {
|
||||
store.setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// =====================
|
||||
// RENDERING
|
||||
// =====================
|
||||
|
||||
render() {
|
||||
this.renderStats();
|
||||
this.renderCompletionChart();
|
||||
this.renderTimeChart();
|
||||
this.renderDueTodayList();
|
||||
this.renderOverdueList();
|
||||
}
|
||||
|
||||
renderStats() {
|
||||
if (!this.stats) return;
|
||||
|
||||
// Open tasks
|
||||
this.updateStatCard('stat-open', this.stats.open || 0);
|
||||
|
||||
// In progress
|
||||
this.updateStatCard('stat-progress', this.stats.inProgress || 0);
|
||||
|
||||
// Completed
|
||||
this.updateStatCard('stat-done', this.stats.completed || 0);
|
||||
|
||||
// Overdue
|
||||
this.updateStatCard('stat-overdue', this.stats.overdue || 0);
|
||||
}
|
||||
|
||||
updateStatCard(id, value) {
|
||||
const valueEl = $(`#${id}`);
|
||||
if (valueEl) {
|
||||
valueEl.textContent = value.toString();
|
||||
}
|
||||
}
|
||||
|
||||
renderCompletionChart() {
|
||||
const container = $('#chart-completed');
|
||||
if (!container || !this.completionData) return;
|
||||
|
||||
clearElement(container);
|
||||
|
||||
// Add bar-chart class
|
||||
container.classList.add('bar-chart');
|
||||
|
||||
const maxValue = Math.max(...this.completionData.map(d => d.count), 1);
|
||||
|
||||
this.completionData.forEach(item => {
|
||||
const percentage = (item.count / maxValue) * 100;
|
||||
|
||||
const barItem = createElement('div', { className: 'bar-item' }, [
|
||||
createElement('span', { className: 'bar-value' }, [item.count.toString()]),
|
||||
createElement('div', {
|
||||
className: 'bar',
|
||||
style: { height: `${Math.max(percentage, 5)}%` }
|
||||
}),
|
||||
createElement('span', { className: 'bar-label' }, [item.label || item.week])
|
||||
]);
|
||||
|
||||
container.appendChild(barItem);
|
||||
});
|
||||
}
|
||||
|
||||
renderTimeChart() {
|
||||
const container = $('#chart-time');
|
||||
if (!container || !this.timeData) return;
|
||||
|
||||
clearElement(container);
|
||||
|
||||
// Add horizontal-bar-chart class
|
||||
container.classList.add('horizontal-bar-chart');
|
||||
|
||||
const totalTime = this.timeData.reduce((sum, item) => sum + (item.totalMinutes || 0), 0);
|
||||
|
||||
this.timeData.slice(0, 5).forEach(item => {
|
||||
const percentage = totalTime > 0 ? ((item.totalMinutes || 0) / totalTime) * 100 : 0;
|
||||
|
||||
const barItem = createElement('div', { className: 'horizontal-bar-item' }, [
|
||||
createElement('div', { className: 'horizontal-bar-header' }, [
|
||||
createElement('span', { className: 'horizontal-bar-label' }, [item.name || item.projectName]),
|
||||
createElement('span', { className: 'horizontal-bar-value' }, [
|
||||
this.formatMinutes(item.totalMinutes || 0)
|
||||
])
|
||||
]),
|
||||
createElement('div', { className: 'horizontal-bar' }, [
|
||||
createElement('div', {
|
||||
className: 'horizontal-bar-fill',
|
||||
style: { width: `${percentage}%` }
|
||||
})
|
||||
])
|
||||
]);
|
||||
|
||||
container.appendChild(barItem);
|
||||
});
|
||||
|
||||
if (this.timeData.length === 0) {
|
||||
container.appendChild(createElement('p', {
|
||||
className: 'text-secondary',
|
||||
style: { textAlign: 'center' }
|
||||
}, ['Keine Zeitdaten verfügbar']));
|
||||
}
|
||||
}
|
||||
|
||||
renderDueTodayList() {
|
||||
const container = $('#due-today-list');
|
||||
if (!container) return;
|
||||
|
||||
clearElement(container);
|
||||
|
||||
if (!this.dueTodayTasks || this.dueTodayTasks.length === 0) {
|
||||
container.appendChild(createElement('p', {
|
||||
className: 'text-secondary empty-message'
|
||||
}, ['Keine Aufgaben für heute']));
|
||||
return;
|
||||
}
|
||||
|
||||
this.dueTodayTasks.slice(0, 5).forEach(task => {
|
||||
const hasAssignee = task.assignedTo || task.assignedName;
|
||||
const item = createElement('div', {
|
||||
className: 'due-today-item',
|
||||
onclick: () => this.openTaskModal(task.id)
|
||||
}, [
|
||||
createElement('span', {
|
||||
className: 'due-today-priority',
|
||||
style: { backgroundColor: this.getPriorityColor(task.priority) }
|
||||
}),
|
||||
createElement('span', { className: 'due-today-title' }, [task.title]),
|
||||
hasAssignee ? createElement('div', { className: 'due-today-assignee' }, [
|
||||
createElement('span', {
|
||||
className: 'avatar avatar-sm',
|
||||
style: { backgroundColor: task.assignedColor || '#888' }
|
||||
}, [getInitials(task.assignedName || 'U')])
|
||||
]) : null
|
||||
].filter(Boolean));
|
||||
|
||||
container.appendChild(item);
|
||||
});
|
||||
|
||||
if (this.dueTodayTasks.length > 5) {
|
||||
container.appendChild(createElement('button', {
|
||||
className: 'btn btn-ghost btn-sm btn-block',
|
||||
onclick: () => this.showAllDueToday()
|
||||
}, [`Alle ${this.dueTodayTasks.length} anzeigen`]));
|
||||
}
|
||||
}
|
||||
|
||||
renderOverdueList() {
|
||||
const container = $('#overdue-list');
|
||||
if (!container) return;
|
||||
|
||||
clearElement(container);
|
||||
|
||||
if (!this.overdueTasks || this.overdueTasks.length === 0) {
|
||||
container.appendChild(createElement('p', {
|
||||
className: 'text-secondary empty-message'
|
||||
}, ['Keine überfälligen Aufgaben']));
|
||||
return;
|
||||
}
|
||||
|
||||
this.overdueTasks.slice(0, 5).forEach(task => {
|
||||
const daysOverdue = this.getDaysOverdue(task.dueDate);
|
||||
|
||||
const item = createElement('div', {
|
||||
className: 'due-today-item overdue-item',
|
||||
onclick: () => this.openTaskModal(task.id)
|
||||
}, [
|
||||
createElement('span', {
|
||||
className: 'due-today-priority',
|
||||
style: { backgroundColor: this.getPriorityColor(task.priority) }
|
||||
}),
|
||||
createElement('div', { style: { flex: 1 } }, [
|
||||
createElement('span', { className: 'due-today-title' }, [task.title]),
|
||||
createElement('span', {
|
||||
className: 'text-error',
|
||||
style: { fontSize: 'var(--text-xs)', display: 'block' }
|
||||
}, [`${daysOverdue} Tag(e) überfällig`])
|
||||
]),
|
||||
task.assignee ? createElement('div', { className: 'due-today-assignee' }, [
|
||||
createElement('span', {
|
||||
className: 'avatar avatar-sm',
|
||||
style: { backgroundColor: task.assignee.color || '#888' }
|
||||
}, [getInitials(task.assignee.username)])
|
||||
]) : null
|
||||
].filter(Boolean));
|
||||
|
||||
container.appendChild(item);
|
||||
});
|
||||
|
||||
if (this.overdueTasks.length > 5) {
|
||||
container.appendChild(createElement('button', {
|
||||
className: 'btn btn-ghost btn-sm btn-block',
|
||||
onclick: () => this.showAllOverdue()
|
||||
}, [`Alle ${this.overdueTasks.length} anzeigen`]));
|
||||
}
|
||||
}
|
||||
|
||||
// =====================
|
||||
// ACTIONS
|
||||
// =====================
|
||||
|
||||
openTaskModal(taskId) {
|
||||
window.dispatchEvent(new CustomEvent('modal:open', {
|
||||
detail: {
|
||||
modalId: 'task-modal',
|
||||
mode: 'edit',
|
||||
data: { taskId }
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
showAllDueToday() {
|
||||
// Switch to list view with due date filter
|
||||
store.setFilter('dueDate', 'today');
|
||||
store.setCurrentView('list');
|
||||
}
|
||||
|
||||
showAllOverdue() {
|
||||
// Switch to list view with overdue filter
|
||||
store.setFilter('dueDate', 'overdue');
|
||||
store.setCurrentView('list');
|
||||
}
|
||||
|
||||
// =====================
|
||||
// HELPERS
|
||||
// =====================
|
||||
|
||||
formatMinutes(minutes) {
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${mins}m`;
|
||||
}
|
||||
return `${mins}m`;
|
||||
}
|
||||
|
||||
getPriorityColor(priority) {
|
||||
const colors = {
|
||||
high: 'var(--priority-high)',
|
||||
medium: 'var(--priority-medium)',
|
||||
low: 'var(--priority-low)'
|
||||
};
|
||||
return colors[priority] || colors.medium;
|
||||
}
|
||||
|
||||
getDaysOverdue(dueDate) {
|
||||
const due = new Date(dueDate);
|
||||
const today = new Date();
|
||||
const diffTime = today - due;
|
||||
return Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
}
|
||||
|
||||
showError(message) {
|
||||
window.dispatchEvent(new CustomEvent('toast:show', {
|
||||
detail: { message, type: 'error' }
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// Create and export singleton
|
||||
const dashboardManager = new DashboardManager();
|
||||
|
||||
export default dashboardManager;
|
||||
851
frontend/js/gitea.js
Normale Datei
851
frontend/js/gitea.js
Normale Datei
@ -0,0 +1,851 @@
|
||||
/**
|
||||
* TASKMATE - Gitea Manager
|
||||
* ========================
|
||||
* Git-Repository-Verwaltung pro Projekt
|
||||
*/
|
||||
|
||||
import api from './api.js';
|
||||
import { $, $$, escapeHtml } from './utils.js';
|
||||
import store from './store.js';
|
||||
|
||||
class GiteaManager {
|
||||
constructor() {
|
||||
this.application = null;
|
||||
this.gitStatus = null;
|
||||
this.branches = [];
|
||||
this.commits = [];
|
||||
this.giteaRepos = [];
|
||||
this.giteaConnected = false;
|
||||
this.initialized = false;
|
||||
this.refreshInterval = null;
|
||||
this.isLoading = false;
|
||||
}
|
||||
|
||||
async init() {
|
||||
if (this.initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
// DOM Elements
|
||||
this.giteaView = $('#view-gitea');
|
||||
this.noProjectSection = $('#gitea-no-project');
|
||||
this.configSection = $('#gitea-config-section');
|
||||
this.mainSection = $('#gitea-main-section');
|
||||
this.connectionStatus = $('#gitea-connection-status');
|
||||
|
||||
this.bindEvents();
|
||||
this.subscribeToStore();
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
// Konfiguration speichern
|
||||
$('#gitea-config-form')?.addEventListener('submit', (e) => this.handleConfigSave(e));
|
||||
|
||||
// Repository auswählen
|
||||
$('#gitea-repo-select')?.addEventListener('change', (e) => this.handleRepoSelect(e));
|
||||
|
||||
// Repositories aktualisieren
|
||||
$('#btn-refresh-repos')?.addEventListener('click', () => this.loadGiteaRepos());
|
||||
|
||||
// Neues Repository erstellen
|
||||
$('#btn-create-repo')?.addEventListener('click', () => this.openCreateRepoModal());
|
||||
|
||||
// Git-Operationen
|
||||
$('#btn-git-fetch')?.addEventListener('click', () => this.handleFetch());
|
||||
$('#btn-git-pull')?.addEventListener('click', () => this.handlePull());
|
||||
$('#btn-git-push')?.addEventListener('click', () => this.handlePush());
|
||||
$('#btn-git-commit')?.addEventListener('click', () => this.openCommitModal());
|
||||
|
||||
// Branch wechseln
|
||||
$('#branch-select')?.addEventListener('change', (e) => this.handleBranchChange(e));
|
||||
|
||||
// Pfad validieren
|
||||
$('#local-path-input')?.addEventListener('blur', (e) => this.validateLocalPath(e.target.value));
|
||||
|
||||
// Konfiguration bearbeiten/entfernen
|
||||
$('#btn-edit-config')?.addEventListener('click', () => this.showConfigSection());
|
||||
$('#btn-remove-config')?.addEventListener('click', () => this.handleRemoveConfig());
|
||||
|
||||
// Create Repo Modal
|
||||
$('#create-repo-form')?.addEventListener('submit', (e) => this.handleCreateRepo(e));
|
||||
|
||||
// Commit Modal
|
||||
$('#git-commit-form')?.addEventListener('submit', (e) => this.handleCommit(e));
|
||||
}
|
||||
|
||||
subscribeToStore() {
|
||||
// Bei Projektwechsel neu laden
|
||||
store.subscribe('currentProjectId', async (projectId) => {
|
||||
if (projectId && store.get('currentView') === 'gitea') {
|
||||
await this.loadApplication();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async loadApplication() {
|
||||
const projectId = store.get('currentProjectId');
|
||||
|
||||
if (!projectId) {
|
||||
this.showNoProjectMessage();
|
||||
return;
|
||||
}
|
||||
|
||||
this.showLoading();
|
||||
|
||||
try {
|
||||
const result = await api.getProjectApplication(projectId);
|
||||
this.application = result;
|
||||
|
||||
if (result.configured) {
|
||||
await this.loadGitData();
|
||||
this.renderConfiguredView();
|
||||
} else {
|
||||
await this.loadGiteaRepos();
|
||||
this.renderConfigurationView();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Gitea] Fehler beim Laden:', error);
|
||||
this.showError('Fehler beim Laden der Konfiguration');
|
||||
}
|
||||
}
|
||||
|
||||
async loadGitData() {
|
||||
const projectId = store.get('currentProjectId');
|
||||
|
||||
try {
|
||||
const [statusResult, branchesResult, commitsResult] = await Promise.allSettled([
|
||||
api.getGitStatus(projectId),
|
||||
api.getGitBranches(projectId),
|
||||
api.getGitCommits(projectId, 10)
|
||||
]);
|
||||
|
||||
if (statusResult.status === 'fulfilled') {
|
||||
this.gitStatus = statusResult.value;
|
||||
} else {
|
||||
this.gitStatus = null;
|
||||
console.error('[Gitea] Status-Fehler:', statusResult.reason);
|
||||
}
|
||||
|
||||
if (branchesResult.status === 'fulfilled') {
|
||||
this.branches = branchesResult.value.branches || [];
|
||||
} else {
|
||||
this.branches = [];
|
||||
}
|
||||
|
||||
if (commitsResult.status === 'fulfilled') {
|
||||
this.commits = commitsResult.value.commits || [];
|
||||
} else {
|
||||
this.commits = [];
|
||||
}
|
||||
|
||||
this.renderStatus();
|
||||
this.renderBranches();
|
||||
this.renderCommits();
|
||||
this.renderChanges();
|
||||
} catch (error) {
|
||||
console.error('[Gitea] Git-Daten laden fehlgeschlagen:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async loadGiteaRepos() {
|
||||
try {
|
||||
const result = await api.testGiteaConnection();
|
||||
this.giteaConnected = result.connected;
|
||||
this.updateConnectionStatus(result);
|
||||
|
||||
if (this.giteaConnected) {
|
||||
const reposResult = await api.getGiteaRepositories();
|
||||
this.giteaRepos = reposResult.repositories || [];
|
||||
this.populateRepoSelect();
|
||||
}
|
||||
} catch (error) {
|
||||
this.giteaConnected = false;
|
||||
this.updateConnectionStatus({ connected: false, error: error.message });
|
||||
console.error('[Gitea] Verbindung fehlgeschlagen:', error);
|
||||
}
|
||||
}
|
||||
|
||||
updateConnectionStatus(result) {
|
||||
const statusEl = this.connectionStatus;
|
||||
if (!statusEl) return;
|
||||
|
||||
statusEl.classList.remove('connected', 'disconnected');
|
||||
|
||||
if (result.connected) {
|
||||
statusEl.classList.add('connected');
|
||||
statusEl.querySelector('.status-text').textContent =
|
||||
`Verbunden als ${result.user?.login || 'Benutzer'}`;
|
||||
} else {
|
||||
statusEl.classList.add('disconnected');
|
||||
statusEl.querySelector('.status-text').textContent =
|
||||
result.error || 'Verbindung fehlgeschlagen';
|
||||
}
|
||||
}
|
||||
|
||||
populateRepoSelect() {
|
||||
const select = $('#gitea-repo-select');
|
||||
if (!select) return;
|
||||
|
||||
select.innerHTML = '<option value="">-- Repository wählen --</option>';
|
||||
|
||||
this.giteaRepos.forEach(repo => {
|
||||
const option = document.createElement('option');
|
||||
option.value = JSON.stringify({
|
||||
url: repo.cloneUrl,
|
||||
owner: repo.owner,
|
||||
name: repo.name,
|
||||
fullName: repo.fullName,
|
||||
htmlUrl: repo.htmlUrl,
|
||||
defaultBranch: repo.defaultBranch
|
||||
});
|
||||
option.textContent = repo.fullName;
|
||||
select.appendChild(option);
|
||||
});
|
||||
}
|
||||
|
||||
handleRepoSelect(e) {
|
||||
const value = e.target.value;
|
||||
if (!value) return;
|
||||
|
||||
try {
|
||||
const repo = JSON.parse(value);
|
||||
$('#default-branch-input').value = repo.defaultBranch || 'main';
|
||||
} catch (error) {
|
||||
console.error('[Gitea] Fehler beim Parsen des Repository:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async validateLocalPath(path) {
|
||||
const resultEl = $('#path-validation-result');
|
||||
if (!resultEl || !path) {
|
||||
if (resultEl) {
|
||||
resultEl.textContent = '';
|
||||
resultEl.className = 'form-hint';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await api.validatePath(path);
|
||||
|
||||
if (result.valid) {
|
||||
if (result.isRepository) {
|
||||
resultEl.textContent = 'Pfad ist ein Git-Repository';
|
||||
resultEl.className = 'form-hint success';
|
||||
} else {
|
||||
resultEl.textContent = 'Pfad ist erreichbar (kein Git-Repository)';
|
||||
resultEl.className = 'form-hint';
|
||||
}
|
||||
} else {
|
||||
resultEl.textContent = 'Pfad nicht erreichbar';
|
||||
resultEl.className = 'form-hint error';
|
||||
}
|
||||
} catch (error) {
|
||||
resultEl.textContent = 'Fehler bei der Validierung';
|
||||
resultEl.className = 'form-hint error';
|
||||
}
|
||||
}
|
||||
|
||||
async handleConfigSave(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const projectId = store.get('currentProjectId');
|
||||
if (!projectId) return;
|
||||
|
||||
const repoSelectValue = $('#gitea-repo-select').value;
|
||||
const localPath = $('#local-path-input').value.trim();
|
||||
const defaultBranch = $('#default-branch-input').value.trim() || 'main';
|
||||
|
||||
if (!localPath) {
|
||||
this.showToast('Bitte geben Sie einen lokalen Pfad an', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
let giteaRepoUrl = null;
|
||||
let giteaRepoOwner = null;
|
||||
let giteaRepoName = null;
|
||||
let cloneUrl = null;
|
||||
|
||||
if (repoSelectValue) {
|
||||
try {
|
||||
const repo = JSON.parse(repoSelectValue);
|
||||
giteaRepoUrl = repo.htmlUrl;
|
||||
giteaRepoOwner = repo.owner;
|
||||
giteaRepoName = repo.name;
|
||||
cloneUrl = repo.url; // Clone URL für git remote
|
||||
} catch (error) {
|
||||
console.error('[Gitea] Fehler beim Parsen des Repository:', error);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Konfiguration speichern
|
||||
const result = await api.saveProjectApplication({
|
||||
projectId,
|
||||
localPath,
|
||||
giteaRepoUrl,
|
||||
giteaRepoOwner,
|
||||
giteaRepoName,
|
||||
defaultBranch
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
// 2. Repository für Gitea vorbereiten (init, remote setzen)
|
||||
if (cloneUrl) {
|
||||
this.showToast('Repository wird vorbereitet...', 'info');
|
||||
const prepareResult = await api.prepareRepository(projectId, cloneUrl, defaultBranch);
|
||||
|
||||
if (prepareResult.success) {
|
||||
this.showToast('Konfiguration gespeichert und Repository vorbereitet', 'success');
|
||||
} else {
|
||||
this.showToast('Konfiguration gespeichert, aber Repository-Vorbereitung fehlgeschlagen: ' + (prepareResult.error || 'Unbekannter Fehler'), 'warning');
|
||||
}
|
||||
} else {
|
||||
this.showToast('Konfiguration gespeichert', 'success');
|
||||
}
|
||||
|
||||
await this.loadApplication();
|
||||
} else {
|
||||
this.showToast(result.error || 'Fehler beim Speichern', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
this.showToast(error.message || 'Fehler beim Speichern', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async handleRemoveConfig() {
|
||||
const projectId = store.get('currentProjectId');
|
||||
if (!projectId) return;
|
||||
|
||||
if (!confirm('Möchten Sie die Repository-Konfiguration wirklich entfernen?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.deleteProjectApplication(projectId);
|
||||
this.showToast('Konfiguration entfernt', 'success');
|
||||
await this.loadApplication();
|
||||
} catch (error) {
|
||||
this.showToast(error.message || 'Fehler beim Entfernen', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
showConfigSection() {
|
||||
this.hideAllSections();
|
||||
this.configSection?.classList.remove('hidden');
|
||||
|
||||
// Formular mit aktuellen Werten füllen
|
||||
if (this.application?.configured) {
|
||||
$('#local-path-input').value = this.application.local_path || '';
|
||||
$('#default-branch-input').value = this.application.default_branch || 'main';
|
||||
|
||||
// Repository im Dropdown auswählen falls vorhanden
|
||||
if (this.application.gitea_repo_url) {
|
||||
const select = $('#gitea-repo-select');
|
||||
const options = select?.querySelectorAll('option');
|
||||
options?.forEach(option => {
|
||||
if (option.value) {
|
||||
try {
|
||||
const repo = JSON.parse(option.value);
|
||||
if (repo.owner === this.application.gitea_repo_owner &&
|
||||
repo.name === this.application.gitea_repo_name) {
|
||||
select.value = option.value;
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.loadGiteaRepos();
|
||||
}
|
||||
|
||||
// Git Operations
|
||||
async handleFetch() {
|
||||
const projectId = store.get('currentProjectId');
|
||||
if (!projectId) return;
|
||||
|
||||
this.setOperationLoading('fetch', true);
|
||||
|
||||
try {
|
||||
const result = await api.gitFetch(projectId);
|
||||
if (result.success) {
|
||||
this.showToast('Fetch erfolgreich', 'success');
|
||||
await this.loadGitData();
|
||||
} else {
|
||||
this.showToast(result.error || 'Fetch fehlgeschlagen', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
this.showToast(error.message || 'Fetch fehlgeschlagen', 'error');
|
||||
} finally {
|
||||
this.setOperationLoading('fetch', false);
|
||||
}
|
||||
}
|
||||
|
||||
async handlePull() {
|
||||
const projectId = store.get('currentProjectId');
|
||||
if (!projectId) return;
|
||||
|
||||
this.setOperationLoading('pull', true);
|
||||
|
||||
try {
|
||||
const branch = $('#branch-select')?.value || null;
|
||||
const result = await api.gitPull(projectId, branch);
|
||||
|
||||
if (result.success) {
|
||||
this.showToast('Pull erfolgreich', 'success');
|
||||
await this.loadGitData();
|
||||
} else {
|
||||
this.showToast(result.error || 'Pull fehlgeschlagen', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
this.showToast(error.message || 'Pull fehlgeschlagen', 'error');
|
||||
} finally {
|
||||
this.setOperationLoading('pull', false);
|
||||
}
|
||||
}
|
||||
|
||||
async handlePush() {
|
||||
const projectId = store.get('currentProjectId');
|
||||
if (!projectId) return;
|
||||
|
||||
this.setOperationLoading('push', true);
|
||||
|
||||
try {
|
||||
const branch = $('#branch-select')?.value || 'main';
|
||||
let result = await api.gitPush(projectId, branch);
|
||||
|
||||
// Falls Push wegen fehlendem Upstream/Remote fehlschlägt, versuche init-push
|
||||
if (!result.success && result.error &&
|
||||
(result.error.includes('No configured push destination') ||
|
||||
result.error.includes('no upstream') ||
|
||||
result.error.includes('Kein Remote'))) {
|
||||
this.showToast('Kein Upstream konfiguriert, führe initialen Push durch...', 'info');
|
||||
result = await api.gitInitPush(projectId, branch);
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
this.showToast('Push erfolgreich', 'success');
|
||||
await this.loadGitData();
|
||||
} else {
|
||||
this.showToast(result.error || 'Push fehlgeschlagen', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
this.showToast(error.message || 'Push fehlgeschlagen', 'error');
|
||||
} finally {
|
||||
this.setOperationLoading('push', false);
|
||||
}
|
||||
}
|
||||
|
||||
openCommitModal() {
|
||||
const modal = $('#git-commit-modal');
|
||||
if (!modal) return;
|
||||
|
||||
$('#commit-message').value = '';
|
||||
$('#commit-stage-all').checked = true;
|
||||
|
||||
modal.classList.remove('hidden');
|
||||
modal.classList.add('visible');
|
||||
$('#modal-overlay')?.classList.remove('hidden');
|
||||
|
||||
store.openModal('git-commit-modal');
|
||||
}
|
||||
|
||||
async handleCommit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const projectId = store.get('currentProjectId');
|
||||
if (!projectId) return;
|
||||
|
||||
const message = $('#commit-message')?.value.trim();
|
||||
const stageAll = $('#commit-stage-all')?.checked ?? true;
|
||||
|
||||
if (!message) {
|
||||
this.showToast('Bitte geben Sie eine Commit-Nachricht ein', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await api.gitCommit(projectId, message, stageAll);
|
||||
|
||||
if (result.success) {
|
||||
this.showToast('Commit erstellt', 'success');
|
||||
this.closeModal('git-commit-modal');
|
||||
await this.loadGitData();
|
||||
} else {
|
||||
this.showToast(result.error || 'Commit fehlgeschlagen', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
this.showToast(error.message || 'Commit fehlgeschlagen', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async handleBranchChange(e) {
|
||||
const projectId = store.get('currentProjectId');
|
||||
const branch = e.target.value;
|
||||
|
||||
if (!projectId || !branch) return;
|
||||
|
||||
try {
|
||||
const result = await api.gitCheckout(projectId, branch);
|
||||
|
||||
if (result.success) {
|
||||
this.showToast(`Gewechselt zu ${branch}`, 'success');
|
||||
await this.loadGitData();
|
||||
} else {
|
||||
this.showToast(result.error || 'Branch-Wechsel fehlgeschlagen', 'error');
|
||||
// Zurück zum vorherigen Branch
|
||||
await this.loadGitData();
|
||||
}
|
||||
} catch (error) {
|
||||
this.showToast(error.message || 'Branch-Wechsel fehlgeschlagen', 'error');
|
||||
await this.loadGitData();
|
||||
}
|
||||
}
|
||||
|
||||
openCreateRepoModal() {
|
||||
const modal = $('#create-repo-modal');
|
||||
if (!modal) return;
|
||||
|
||||
$('#new-repo-name').value = '';
|
||||
$('#new-repo-description').value = '';
|
||||
$('#new-repo-private').checked = true;
|
||||
|
||||
modal.classList.remove('hidden');
|
||||
modal.classList.add('visible');
|
||||
$('#modal-overlay')?.classList.remove('hidden');
|
||||
|
||||
store.openModal('create-repo-modal');
|
||||
}
|
||||
|
||||
async handleCreateRepo(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const name = $('#new-repo-name')?.value.trim();
|
||||
const description = $('#new-repo-description')?.value.trim();
|
||||
const isPrivate = $('#new-repo-private')?.checked ?? true;
|
||||
|
||||
if (!name) {
|
||||
this.showToast('Bitte geben Sie einen Repository-Namen ein', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await api.createGiteaRepository({
|
||||
name,
|
||||
description,
|
||||
private: isPrivate
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
this.showToast('Repository erstellt', 'success');
|
||||
this.closeModal('create-repo-modal');
|
||||
await this.loadGiteaRepos();
|
||||
|
||||
// Neues Repository im Dropdown auswählen
|
||||
const select = $('#gitea-repo-select');
|
||||
if (select && result.repository) {
|
||||
const option = document.createElement('option');
|
||||
option.value = JSON.stringify({
|
||||
url: result.repository.cloneUrl,
|
||||
owner: result.repository.owner,
|
||||
name: result.repository.name,
|
||||
fullName: result.repository.fullName,
|
||||
htmlUrl: result.repository.htmlUrl,
|
||||
defaultBranch: result.repository.defaultBranch
|
||||
});
|
||||
option.textContent = result.repository.fullName;
|
||||
select.appendChild(option);
|
||||
select.value = option.value;
|
||||
}
|
||||
} else {
|
||||
this.showToast(result.error || 'Repository-Erstellung fehlgeschlagen', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
this.showToast(error.message || 'Repository-Erstellung fehlgeschlagen', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Rendering
|
||||
renderConfigurationView() {
|
||||
this.hideAllSections();
|
||||
this.configSection?.classList.remove('hidden');
|
||||
|
||||
// Formular zurücksetzen
|
||||
$('#gitea-repo-select').value = '';
|
||||
$('#local-path-input').value = '';
|
||||
$('#default-branch-input').value = 'main';
|
||||
$('#path-validation-result').textContent = '';
|
||||
}
|
||||
|
||||
renderConfiguredView() {
|
||||
this.hideAllSections();
|
||||
this.mainSection?.classList.remove('hidden');
|
||||
|
||||
// Repository-Info
|
||||
const repoNameEl = $('#gitea-repo-name span');
|
||||
const repoUrlEl = $('#gitea-repo-url');
|
||||
const localPathEl = $('#gitea-local-path-display');
|
||||
|
||||
if (this.application) {
|
||||
if (this.application.gitea_repo_owner && this.application.gitea_repo_name) {
|
||||
repoNameEl.textContent = `${this.application.gitea_repo_owner}/${this.application.gitea_repo_name}`;
|
||||
} else {
|
||||
repoNameEl.textContent = 'Lokales Repository';
|
||||
}
|
||||
|
||||
if (this.application.gitea_repo_url) {
|
||||
repoUrlEl.href = this.application.gitea_repo_url;
|
||||
repoUrlEl.textContent = this.application.gitea_repo_url;
|
||||
repoUrlEl.classList.remove('hidden');
|
||||
} else {
|
||||
repoUrlEl.classList.add('hidden');
|
||||
}
|
||||
|
||||
localPathEl.textContent = this.application.local_path || '-';
|
||||
}
|
||||
}
|
||||
|
||||
renderStatus() {
|
||||
const statusBadge = $('#git-status-indicator');
|
||||
const changesCount = $('#git-changes-count');
|
||||
const aheadBehind = $('#git-ahead-behind');
|
||||
|
||||
if (!this.gitStatus) {
|
||||
statusBadge.textContent = 'Fehler';
|
||||
statusBadge.className = 'status-badge error';
|
||||
changesCount.textContent = '-';
|
||||
aheadBehind.textContent = '- / -';
|
||||
return;
|
||||
}
|
||||
|
||||
// Status Badge
|
||||
if (!this.gitStatus.success) {
|
||||
statusBadge.textContent = 'Fehler';
|
||||
statusBadge.className = 'status-badge error';
|
||||
} else if (this.gitStatus.isClean) {
|
||||
statusBadge.textContent = 'Sauber';
|
||||
statusBadge.className = 'status-badge clean';
|
||||
} else if (this.gitStatus.hasChanges) {
|
||||
statusBadge.textContent = 'Geändert';
|
||||
statusBadge.className = 'status-badge dirty';
|
||||
} else if (this.gitStatus.ahead > 0) {
|
||||
statusBadge.textContent = 'Voraus';
|
||||
statusBadge.className = 'status-badge ahead';
|
||||
} else {
|
||||
statusBadge.textContent = 'OK';
|
||||
statusBadge.className = 'status-badge clean';
|
||||
}
|
||||
|
||||
// Änderungen
|
||||
const changes = this.gitStatus.changes || [];
|
||||
changesCount.textContent = changes.length;
|
||||
|
||||
// Ahead/Behind
|
||||
const ahead = this.gitStatus.ahead || 0;
|
||||
const behind = this.gitStatus.behind || 0;
|
||||
aheadBehind.textContent = `${ahead} / ${behind}`;
|
||||
}
|
||||
|
||||
renderBranches() {
|
||||
const select = $('#branch-select');
|
||||
if (!select) return;
|
||||
|
||||
const currentBranch = this.gitStatus?.branch || 'main';
|
||||
|
||||
select.innerHTML = '';
|
||||
|
||||
// Lokale Branches zuerst
|
||||
const localBranches = this.branches.filter(b => !b.isRemote);
|
||||
localBranches.forEach(branch => {
|
||||
const option = document.createElement('option');
|
||||
option.value = branch.name;
|
||||
option.textContent = branch.name;
|
||||
if (branch.name === currentBranch) {
|
||||
option.selected = true;
|
||||
}
|
||||
select.appendChild(option);
|
||||
});
|
||||
}
|
||||
|
||||
renderCommits() {
|
||||
const listEl = $('#git-commits-list');
|
||||
if (!listEl) return;
|
||||
|
||||
if (this.commits.length === 0) {
|
||||
listEl.innerHTML = '<div class="gitea-empty-state" style="padding: var(--spacing-4);"><p>Keine Commits gefunden</p></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
listEl.innerHTML = this.commits.map(commit => `
|
||||
<div class="commit-item">
|
||||
<span class="commit-hash">${escapeHtml(commit.shortHash || commit.sha?.substring(0, 7))}</span>
|
||||
<div class="commit-info">
|
||||
<div class="commit-message">${escapeHtml(commit.message?.split('\n')[0] || '')}</div>
|
||||
<div class="commit-meta">
|
||||
<span class="author">${escapeHtml(commit.author)}</span> · ${this.formatDate(commit.date)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
renderChanges() {
|
||||
const changesSection = $('#gitea-changes-section');
|
||||
const listEl = $('#git-changes-list');
|
||||
|
||||
if (!changesSection || !listEl) return;
|
||||
|
||||
const changes = this.gitStatus?.changes || [];
|
||||
|
||||
if (changes.length === 0) {
|
||||
changesSection.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
changesSection.classList.remove('hidden');
|
||||
|
||||
listEl.innerHTML = changes.map(change => {
|
||||
const statusClass = this.getChangeStatusClass(change.status);
|
||||
const statusLabel = this.getChangeStatusLabel(change.status);
|
||||
|
||||
return `
|
||||
<div class="change-item">
|
||||
<span class="change-status ${statusClass}" title="${statusLabel}">${change.status}</span>
|
||||
<span class="change-file">${escapeHtml(change.file)}</span>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
getChangeStatusClass(status) {
|
||||
const first = status.charAt(0);
|
||||
const second = status.charAt(1);
|
||||
|
||||
if (first === 'M' || second === 'M') return 'modified';
|
||||
if (first === 'A' || second === 'A') return 'added';
|
||||
if (first === 'D' || second === 'D') return 'deleted';
|
||||
if (first === 'R' || second === 'R') return 'renamed';
|
||||
if (first === '?' || second === '?') return 'untracked';
|
||||
return '';
|
||||
}
|
||||
|
||||
getChangeStatusLabel(status) {
|
||||
const first = status.charAt(0);
|
||||
const second = status.charAt(1);
|
||||
|
||||
if (first === 'M' || second === 'M') return 'Geändert';
|
||||
if (first === 'A' || second === 'A') return 'Hinzugefügt';
|
||||
if (first === 'D' || second === 'D') return 'Gelöscht';
|
||||
if (first === 'R' || second === 'R') return 'Umbenannt';
|
||||
if (first === '?' || second === '?') return 'Nicht verfolgt';
|
||||
return status;
|
||||
}
|
||||
|
||||
formatDate(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diffMs = now - date;
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return 'gerade eben';
|
||||
if (diffMins < 60) return `vor ${diffMins} Minute${diffMins !== 1 ? 'n' : ''}`;
|
||||
if (diffHours < 24) return `vor ${diffHours} Stunde${diffHours !== 1 ? 'n' : ''}`;
|
||||
if (diffDays < 7) return `vor ${diffDays} Tag${diffDays !== 1 ? 'en' : ''}`;
|
||||
|
||||
// Lokale Formatierung
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const year = date.getFullYear();
|
||||
return `${day}.${month}.${year}`;
|
||||
}
|
||||
|
||||
// UI Helpers
|
||||
hideAllSections() {
|
||||
this.noProjectSection?.classList.add('hidden');
|
||||
this.configSection?.classList.add('hidden');
|
||||
this.mainSection?.classList.add('hidden');
|
||||
}
|
||||
|
||||
showNoProjectMessage() {
|
||||
this.hideAllSections();
|
||||
this.noProjectSection?.classList.remove('hidden');
|
||||
}
|
||||
|
||||
showLoading() {
|
||||
this.isLoading = true;
|
||||
}
|
||||
|
||||
showError(message) {
|
||||
this.showToast(message, 'error');
|
||||
}
|
||||
|
||||
showToast(message, type = 'info') {
|
||||
window.dispatchEvent(new CustomEvent('toast:show', {
|
||||
detail: { message, type }
|
||||
}));
|
||||
}
|
||||
|
||||
closeModal(modalId) {
|
||||
const modal = $(`#${modalId}`);
|
||||
if (modal) {
|
||||
modal.classList.add('hidden');
|
||||
modal.classList.remove('visible');
|
||||
}
|
||||
$('#modal-overlay')?.classList.add('hidden');
|
||||
store.closeModal(modalId);
|
||||
}
|
||||
|
||||
setOperationLoading(operation, loading) {
|
||||
const buttonId = `btn-git-${operation}`;
|
||||
const button = $(`#${buttonId}`);
|
||||
if (button) {
|
||||
button.disabled = loading;
|
||||
if (loading) {
|
||||
button.classList.add('loading');
|
||||
} else {
|
||||
button.classList.remove('loading');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// View Control
|
||||
show() {
|
||||
this.giteaView?.classList.remove('hidden');
|
||||
this.giteaView?.classList.add('active');
|
||||
this.loadApplication();
|
||||
this.startAutoRefresh();
|
||||
}
|
||||
|
||||
hide() {
|
||||
this.giteaView?.classList.add('hidden');
|
||||
this.giteaView?.classList.remove('active');
|
||||
this.stopAutoRefresh();
|
||||
}
|
||||
|
||||
startAutoRefresh() {
|
||||
this.stopAutoRefresh();
|
||||
// Status alle 30 Sekunden aktualisieren
|
||||
this.refreshInterval = setInterval(() => {
|
||||
if (this.application?.configured && !this.isLoading) {
|
||||
this.loadGitData();
|
||||
}
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
stopAutoRefresh() {
|
||||
if (this.refreshInterval) {
|
||||
clearInterval(this.refreshInterval);
|
||||
this.refreshInterval = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const giteaManager = new GiteaManager();
|
||||
export { giteaManager };
|
||||
export default giteaManager;
|
||||
641
frontend/js/list.js
Normale Datei
641
frontend/js/list.js
Normale Datei
@ -0,0 +1,641 @@
|
||||
/**
|
||||
* TASKMATE - List View Module
|
||||
* ===========================
|
||||
* Tabellarische Listenansicht der Aufgaben
|
||||
* Unterstützt gruppierte und flache Ansicht mit Inline-Bearbeitung
|
||||
*/
|
||||
|
||||
import store from './store.js';
|
||||
import api from './api.js';
|
||||
import {
|
||||
$, $$, createElement, clearElement, formatDate,
|
||||
getDueDateStatus, filterTasks, getInitials, hexToRgba,
|
||||
getContrastColor, groupBy, sortBy, escapeHtml
|
||||
} from './utils.js';
|
||||
|
||||
class ListViewManager {
|
||||
constructor() {
|
||||
// DOM Elements
|
||||
this.container = null;
|
||||
this.contentElement = null;
|
||||
this.sortSelect = null;
|
||||
this.sortDirectionBtn = null;
|
||||
|
||||
// State
|
||||
this.viewMode = 'grouped'; // 'grouped' | 'flat'
|
||||
this.sortColumn = 'dueDate';
|
||||
this.sortDirection = 'asc';
|
||||
this.collapsedGroups = new Set();
|
||||
|
||||
// Inline editing state
|
||||
this.editingCell = null;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.container = $('#view-list');
|
||||
this.contentElement = $('#list-content');
|
||||
this.sortSelect = $('#list-sort-select');
|
||||
this.sortDirectionBtn = $('#list-sort-direction');
|
||||
|
||||
if (!this.container) return;
|
||||
|
||||
this.bindEvents();
|
||||
|
||||
// Subscribe to store changes for real-time updates
|
||||
store.subscribe('tasks', () => this.render());
|
||||
store.subscribe('columns', () => this.render());
|
||||
store.subscribe('filters', () => this.render());
|
||||
store.subscribe('searchResultIds', () => this.render());
|
||||
store.subscribe('users', () => this.render());
|
||||
store.subscribe('labels', () => this.render());
|
||||
store.subscribe('currentView', (view) => {
|
||||
if (view === 'list') this.render();
|
||||
});
|
||||
|
||||
// Listen for app refresh events
|
||||
window.addEventListener('app:refresh', () => this.render());
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
// View mode toggle
|
||||
$$('.list-toggle-btn', this.container).forEach(btn => {
|
||||
btn.addEventListener('click', () => this.setViewMode(btn.dataset.mode));
|
||||
});
|
||||
|
||||
// Sort select
|
||||
if (this.sortSelect) {
|
||||
this.sortSelect.addEventListener('change', () => {
|
||||
this.sortColumn = this.sortSelect.value;
|
||||
this.render();
|
||||
});
|
||||
}
|
||||
|
||||
// Sort direction button
|
||||
if (this.sortDirectionBtn) {
|
||||
this.sortDirectionBtn.addEventListener('click', () => {
|
||||
this.sortDirection = this.sortDirection === 'asc' ? 'desc' : 'asc';
|
||||
this.sortDirectionBtn.classList.toggle('asc', this.sortDirection === 'asc');
|
||||
this.render();
|
||||
});
|
||||
}
|
||||
|
||||
// Delegate click events on content
|
||||
if (this.contentElement) {
|
||||
this.contentElement.addEventListener('click', (e) => this.handleContentClick(e));
|
||||
this.contentElement.addEventListener('change', (e) => this.handleContentChange(e));
|
||||
this.contentElement.addEventListener('dblclick', (e) => this.handleDoubleClick(e));
|
||||
}
|
||||
}
|
||||
|
||||
setViewMode(mode) {
|
||||
this.viewMode = mode;
|
||||
|
||||
// Update toggle buttons
|
||||
$$('.list-toggle-btn', this.container).forEach(btn => {
|
||||
btn.classList.toggle('active', btn.dataset.mode === mode);
|
||||
});
|
||||
|
||||
this.render();
|
||||
}
|
||||
|
||||
// =====================
|
||||
// RENDERING
|
||||
// =====================
|
||||
|
||||
render() {
|
||||
if (!this.contentElement) return;
|
||||
if (store.get('currentView') !== 'list') return;
|
||||
|
||||
const tasks = this.getFilteredAndSortedTasks();
|
||||
|
||||
if (tasks.length === 0) {
|
||||
this.renderEmpty();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.viewMode === 'grouped') {
|
||||
this.renderGrouped(tasks);
|
||||
} else {
|
||||
this.renderFlat(tasks);
|
||||
}
|
||||
}
|
||||
|
||||
getFilteredAndSortedTasks() {
|
||||
const tasks = store.get('tasks').filter(t => !t.archived);
|
||||
const filters = store.get('filters');
|
||||
const searchResultIds = store.get('searchResultIds') || [];
|
||||
const columns = store.get('columns');
|
||||
|
||||
// Apply filters
|
||||
let filtered = filterTasks(tasks, filters, searchResultIds, columns);
|
||||
|
||||
// Apply sorting
|
||||
filtered = this.sortTasks(filtered);
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
sortTasks(tasks) {
|
||||
const columns = store.get('columns');
|
||||
const users = store.get('users');
|
||||
|
||||
return sortBy(tasks, (task) => {
|
||||
switch (this.sortColumn) {
|
||||
case 'title':
|
||||
return task.title?.toLowerCase() || '';
|
||||
case 'priority':
|
||||
const priorityOrder = { high: 0, medium: 1, low: 2 };
|
||||
return priorityOrder[task.priority] ?? 1;
|
||||
case 'dueDate':
|
||||
return task.dueDate ? new Date(task.dueDate).getTime() : Infinity;
|
||||
case 'status':
|
||||
const colIndex = columns.findIndex(c => c.id === task.columnId);
|
||||
return colIndex >= 0 ? colIndex : Infinity;
|
||||
case 'assignee':
|
||||
const user = users.find(u => u.id === task.assignedTo);
|
||||
return user?.displayName?.toLowerCase() || 'zzz';
|
||||
default:
|
||||
return task.title?.toLowerCase() || '';
|
||||
}
|
||||
}, this.sortDirection);
|
||||
}
|
||||
|
||||
renderEmpty() {
|
||||
this.contentElement.innerHTML = `
|
||||
<div class="list-empty">
|
||||
<svg viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" stroke="currentColor" stroke-width="2" fill="none"/><path d="M14 2v6h6M16 13H8M16 17H8M10 9H8" stroke="currentColor" stroke-width="2" fill="none"/></svg>
|
||||
<h3>Keine Aufgaben gefunden</h3>
|
||||
<p>Erstellen Sie eine neue Aufgabe oder ändern Sie die Filter.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
renderGrouped(tasks) {
|
||||
const columns = store.get('columns');
|
||||
const tasksByColumn = groupBy(tasks, 'columnId');
|
||||
|
||||
clearElement(this.contentElement);
|
||||
|
||||
columns.forEach(column => {
|
||||
const columnTasks = tasksByColumn[column.id] || [];
|
||||
if (columnTasks.length === 0) return;
|
||||
|
||||
const isCollapsed = this.collapsedGroups.has(column.id);
|
||||
|
||||
const group = createElement('div', { className: 'list-group' });
|
||||
|
||||
// Group header
|
||||
const header = createElement('div', {
|
||||
className: `list-group-header ${isCollapsed ? 'collapsed' : ''}`,
|
||||
dataset: { columnId: column.id }
|
||||
});
|
||||
|
||||
header.innerHTML = `
|
||||
<svg viewBox="0 0 24 24"><path d="m6 9 6 6 6-6" stroke="currentColor" stroke-width="2" fill="none"/></svg>
|
||||
<span class="list-group-color" style="background-color: ${column.color || '#6366F1'}"></span>
|
||||
<span class="list-group-title">${escapeHtml(column.name)}</span>
|
||||
<span class="list-group-count">${columnTasks.length} Aufgabe${columnTasks.length !== 1 ? 'n' : ''}</span>
|
||||
`;
|
||||
|
||||
header.addEventListener('click', () => this.toggleGroup(column.id));
|
||||
|
||||
group.appendChild(header);
|
||||
|
||||
// Group content (table)
|
||||
const content = createElement('div', {
|
||||
className: `list-group-content ${isCollapsed ? 'collapsed' : ''}`
|
||||
});
|
||||
|
||||
// Table header
|
||||
content.appendChild(this.renderTableHeader());
|
||||
|
||||
// Table rows
|
||||
columnTasks.forEach(task => {
|
||||
content.appendChild(this.renderTableRow(task, column));
|
||||
});
|
||||
|
||||
group.appendChild(content);
|
||||
this.contentElement.appendChild(group);
|
||||
});
|
||||
}
|
||||
|
||||
renderFlat(tasks) {
|
||||
clearElement(this.contentElement);
|
||||
|
||||
const tableContainer = createElement('div', { className: 'list-table' });
|
||||
|
||||
// Table header
|
||||
tableContainer.appendChild(this.renderTableHeader());
|
||||
|
||||
// Table rows
|
||||
const columns = store.get('columns');
|
||||
tasks.forEach(task => {
|
||||
const column = columns.find(c => c.id === task.columnId);
|
||||
tableContainer.appendChild(this.renderTableRow(task, column));
|
||||
});
|
||||
|
||||
this.contentElement.appendChild(tableContainer);
|
||||
}
|
||||
|
||||
renderTableHeader() {
|
||||
const header = createElement('div', { className: 'list-table-header' });
|
||||
|
||||
const columnDefs = [
|
||||
{ key: 'title', label: 'Aufgabe' },
|
||||
{ key: 'status', label: 'Status' },
|
||||
{ key: 'priority', label: 'Priorität' },
|
||||
{ key: 'dueDate', label: 'Fällig' },
|
||||
{ key: 'assignee', label: 'Zugewiesen' }
|
||||
];
|
||||
|
||||
columnDefs.forEach(col => {
|
||||
const isSorted = this.sortColumn === col.key;
|
||||
const span = createElement('span', {
|
||||
className: isSorted ? `sorted ${this.sortDirection}` : '',
|
||||
dataset: { sortKey: col.key }
|
||||
});
|
||||
|
||||
span.innerHTML = `
|
||||
${col.label}
|
||||
<svg viewBox="0 0 24 24"><path d="m6 9 6 6 6-6" stroke="currentColor" stroke-width="2" fill="none"/></svg>
|
||||
`;
|
||||
|
||||
span.addEventListener('click', () => {
|
||||
if (this.sortColumn === col.key) {
|
||||
this.sortDirection = this.sortDirection === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
this.sortColumn = col.key;
|
||||
this.sortDirection = 'asc';
|
||||
}
|
||||
if (this.sortSelect) this.sortSelect.value = this.sortColumn;
|
||||
if (this.sortDirectionBtn) {
|
||||
this.sortDirectionBtn.classList.toggle('asc', this.sortDirection === 'asc');
|
||||
}
|
||||
this.render();
|
||||
});
|
||||
|
||||
header.appendChild(span);
|
||||
});
|
||||
|
||||
return header;
|
||||
}
|
||||
|
||||
renderTableRow(task, column) {
|
||||
const row = createElement('div', {
|
||||
className: 'list-row',
|
||||
dataset: { taskId: task.id }
|
||||
});
|
||||
|
||||
// Title cell
|
||||
row.appendChild(this.renderTitleCell(task, column));
|
||||
|
||||
// Status cell
|
||||
row.appendChild(this.renderStatusCell(task, column));
|
||||
|
||||
// Priority cell
|
||||
row.appendChild(this.renderPriorityCell(task));
|
||||
|
||||
// Due date cell
|
||||
row.appendChild(this.renderDueDateCell(task));
|
||||
|
||||
// Assignee cell
|
||||
row.appendChild(this.renderAssigneeCell(task));
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
renderTitleCell(task, column) {
|
||||
const cell = createElement('div', { className: 'list-cell list-cell-title' });
|
||||
|
||||
// Color indicator
|
||||
const colorDot = createElement('span', {
|
||||
className: 'status-dot',
|
||||
style: { backgroundColor: column?.color || '#6366F1' }
|
||||
});
|
||||
cell.appendChild(colorDot);
|
||||
|
||||
// Title text (clickable to open task)
|
||||
const titleSpan = createElement('span', {
|
||||
dataset: { action: 'open-task', taskId: task.id }
|
||||
}, [escapeHtml(task.title)]);
|
||||
|
||||
cell.appendChild(titleSpan);
|
||||
|
||||
return cell;
|
||||
}
|
||||
|
||||
renderStatusCell(task, column) {
|
||||
const columns = store.get('columns');
|
||||
const cell = createElement('div', { className: 'list-cell list-cell-status list-cell-editable' });
|
||||
|
||||
// Status dot
|
||||
const dot = createElement('span', {
|
||||
className: 'status-dot',
|
||||
style: { backgroundColor: column?.color || '#6366F1' }
|
||||
});
|
||||
cell.appendChild(dot);
|
||||
|
||||
// Status dropdown
|
||||
const select = createElement('select', {
|
||||
dataset: { field: 'columnId', taskId: task.id }
|
||||
});
|
||||
|
||||
columns.forEach(col => {
|
||||
const option = createElement('option', {
|
||||
value: col.id,
|
||||
selected: col.id === task.columnId
|
||||
}, [col.name]);
|
||||
select.appendChild(option);
|
||||
});
|
||||
|
||||
cell.appendChild(select);
|
||||
|
||||
return cell;
|
||||
}
|
||||
|
||||
renderPriorityCell(task) {
|
||||
const cell = createElement('div', {
|
||||
className: `list-cell list-cell-priority ${task.priority || 'medium'} list-cell-editable`
|
||||
});
|
||||
|
||||
const select = createElement('select', {
|
||||
dataset: { field: 'priority', taskId: task.id }
|
||||
});
|
||||
|
||||
const priorities = [
|
||||
{ value: 'high', label: 'Hoch' },
|
||||
{ value: 'medium', label: 'Mittel' },
|
||||
{ value: 'low', label: 'Niedrig' }
|
||||
];
|
||||
|
||||
priorities.forEach(p => {
|
||||
const option = createElement('option', {
|
||||
value: p.value,
|
||||
selected: p.value === (task.priority || 'medium')
|
||||
}, [p.label]);
|
||||
select.appendChild(option);
|
||||
});
|
||||
|
||||
cell.appendChild(select);
|
||||
|
||||
return cell;
|
||||
}
|
||||
|
||||
renderDueDateCell(task) {
|
||||
const status = getDueDateStatus(task.dueDate);
|
||||
let className = 'list-cell list-cell-date list-cell-editable';
|
||||
|
||||
if (status === 'overdue') className += ' overdue';
|
||||
else if (status === 'today') className += ' today';
|
||||
|
||||
const cell = createElement('div', { className });
|
||||
|
||||
const input = createElement('input', {
|
||||
type: 'date',
|
||||
value: task.dueDate ? this.formatDateForInput(task.dueDate) : '',
|
||||
dataset: { field: 'dueDate', taskId: task.id }
|
||||
});
|
||||
|
||||
cell.appendChild(input);
|
||||
|
||||
return cell;
|
||||
}
|
||||
|
||||
renderAssigneeCell(task) {
|
||||
const users = store.get('users');
|
||||
const cell = createElement('div', { className: 'list-cell list-cell-assignee list-cell-editable' });
|
||||
|
||||
const assignedUser = users.find(u => u.id === task.assignedTo);
|
||||
|
||||
// Avatar
|
||||
if (assignedUser) {
|
||||
const avatar = createElement('div', {
|
||||
className: 'avatar',
|
||||
style: { backgroundColor: assignedUser.color || '#6366F1' }
|
||||
}, [getInitials(assignedUser.displayName)]);
|
||||
cell.appendChild(avatar);
|
||||
}
|
||||
|
||||
// User dropdown
|
||||
const select = createElement('select', {
|
||||
dataset: { field: 'assignedTo', taskId: task.id }
|
||||
});
|
||||
|
||||
// Empty option
|
||||
const emptyOption = createElement('option', { value: '' }, ['Nicht zugewiesen']);
|
||||
select.appendChild(emptyOption);
|
||||
|
||||
users.forEach(user => {
|
||||
const option = createElement('option', {
|
||||
value: user.id,
|
||||
selected: user.id === task.assignedTo
|
||||
}, [user.displayName]);
|
||||
select.appendChild(option);
|
||||
});
|
||||
|
||||
cell.appendChild(select);
|
||||
|
||||
return cell;
|
||||
}
|
||||
|
||||
// =====================
|
||||
// EVENT HANDLERS
|
||||
// =====================
|
||||
|
||||
handleContentClick(e) {
|
||||
const target = e.target.closest('[data-action]');
|
||||
if (!target) return;
|
||||
|
||||
const action = target.dataset.action;
|
||||
const taskId = target.dataset.taskId;
|
||||
|
||||
if (action === 'open-task' && taskId) {
|
||||
this.openTask(parseInt(taskId));
|
||||
}
|
||||
}
|
||||
|
||||
handleContentChange(e) {
|
||||
const target = e.target;
|
||||
const field = target.dataset.field;
|
||||
const taskId = target.dataset.taskId;
|
||||
|
||||
if (field && taskId) {
|
||||
this.updateTaskField(parseInt(taskId), field, target.value);
|
||||
}
|
||||
}
|
||||
|
||||
handleDoubleClick(e) {
|
||||
const titleCell = e.target.closest('.list-cell-title span[data-action="open-task"]');
|
||||
if (titleCell) {
|
||||
const taskId = titleCell.dataset.taskId;
|
||||
if (taskId) {
|
||||
this.startInlineEdit(parseInt(taskId), titleCell);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
toggleGroup(columnId) {
|
||||
if (this.collapsedGroups.has(columnId)) {
|
||||
this.collapsedGroups.delete(columnId);
|
||||
} else {
|
||||
this.collapsedGroups.add(columnId);
|
||||
}
|
||||
|
||||
// Update DOM without full re-render
|
||||
const header = this.contentElement.querySelector(`.list-group-header[data-column-id="${columnId}"]`);
|
||||
const content = header?.nextElementSibling;
|
||||
|
||||
if (header && content) {
|
||||
header.classList.toggle('collapsed');
|
||||
content.classList.toggle('collapsed');
|
||||
}
|
||||
}
|
||||
|
||||
// =====================
|
||||
// INLINE EDITING
|
||||
// =====================
|
||||
|
||||
startInlineEdit(taskId, element) {
|
||||
if (this.editingCell) {
|
||||
this.cancelInlineEdit();
|
||||
}
|
||||
|
||||
const task = store.get('tasks').find(t => t.id === taskId);
|
||||
if (!task) return;
|
||||
|
||||
this.editingCell = { taskId, element, originalValue: task.title };
|
||||
|
||||
const input = createElement('input', {
|
||||
type: 'text',
|
||||
className: 'list-inline-input',
|
||||
value: task.title
|
||||
});
|
||||
|
||||
input.addEventListener('blur', () => this.finishInlineEdit());
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
this.finishInlineEdit();
|
||||
} else if (e.key === 'Escape') {
|
||||
this.cancelInlineEdit();
|
||||
}
|
||||
});
|
||||
|
||||
element.textContent = '';
|
||||
element.appendChild(input);
|
||||
input.focus();
|
||||
input.select();
|
||||
}
|
||||
|
||||
async finishInlineEdit() {
|
||||
if (!this.editingCell) return;
|
||||
|
||||
const { taskId, element } = this.editingCell;
|
||||
const input = element.querySelector('input');
|
||||
const newValue = input?.value?.trim();
|
||||
|
||||
if (newValue && newValue !== this.editingCell.originalValue) {
|
||||
await this.updateTaskField(taskId, 'title', newValue);
|
||||
}
|
||||
|
||||
this.editingCell = null;
|
||||
this.render();
|
||||
}
|
||||
|
||||
cancelInlineEdit() {
|
||||
if (!this.editingCell) return;
|
||||
|
||||
const { element, originalValue } = this.editingCell;
|
||||
element.textContent = originalValue;
|
||||
this.editingCell = null;
|
||||
}
|
||||
|
||||
// =====================
|
||||
// API OPERATIONS
|
||||
// =====================
|
||||
|
||||
async updateTaskField(taskId, field, value) {
|
||||
const projectId = store.get('currentProjectId');
|
||||
if (!projectId) return;
|
||||
|
||||
const task = store.get('tasks').find(t => t.id === taskId);
|
||||
if (!task) return;
|
||||
|
||||
// Prepare update data
|
||||
let updateData = {};
|
||||
|
||||
if (field === 'columnId') {
|
||||
updateData.columnId = parseInt(value);
|
||||
} else if (field === 'assignedTo') {
|
||||
updateData.assignedTo = value ? parseInt(value) : null;
|
||||
} else if (field === 'dueDate') {
|
||||
updateData.dueDate = value || null;
|
||||
} else if (field === 'priority') {
|
||||
updateData.priority = value;
|
||||
} else if (field === 'title') {
|
||||
updateData.title = value;
|
||||
}
|
||||
|
||||
// Optimistic update
|
||||
const tasks = store.get('tasks').map(t => {
|
||||
if (t.id === taskId) {
|
||||
return { ...t, ...updateData };
|
||||
}
|
||||
return t;
|
||||
});
|
||||
store.set('tasks', tasks);
|
||||
|
||||
try {
|
||||
await api.updateTask(projectId, taskId, updateData);
|
||||
|
||||
// Dispatch refresh event
|
||||
window.dispatchEvent(new CustomEvent('app:refresh'));
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Aktualisieren der Aufgabe:', error);
|
||||
|
||||
// Rollback on error
|
||||
const originalTasks = store.get('tasks').map(t => {
|
||||
if (t.id === taskId) {
|
||||
return task;
|
||||
}
|
||||
return t;
|
||||
});
|
||||
store.set('tasks', originalTasks);
|
||||
|
||||
// Show error notification
|
||||
window.dispatchEvent(new CustomEvent('toast:show', {
|
||||
detail: {
|
||||
type: 'error',
|
||||
message: 'Fehler beim Speichern der Änderung'
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
openTask(taskId) {
|
||||
const task = store.get('tasks').find(t => t.id === taskId);
|
||||
if (task) {
|
||||
window.dispatchEvent(new CustomEvent('task:edit', { detail: { task } }));
|
||||
}
|
||||
}
|
||||
|
||||
// =====================
|
||||
// UTILITIES
|
||||
// =====================
|
||||
|
||||
formatDateForInput(dateString) {
|
||||
if (!dateString) return '';
|
||||
const date = new Date(dateString);
|
||||
// Use local date formatting (NOT toISOString!)
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Create and export singleton instance
|
||||
const listViewManager = new ListViewManager();
|
||||
export default listViewManager;
|
||||
452
frontend/js/notifications.js
Normale Datei
452
frontend/js/notifications.js
Normale Datei
@ -0,0 +1,452 @@
|
||||
/**
|
||||
* TASKMATE - Notification Manager
|
||||
* ================================
|
||||
* Frontend-Verwaltung für das Benachrichtigungssystem
|
||||
*/
|
||||
|
||||
import api from './api.js';
|
||||
import store from './store.js';
|
||||
|
||||
class NotificationManager {
|
||||
constructor() {
|
||||
this.notifications = [];
|
||||
this.unreadCount = 0;
|
||||
this.isDropdownOpen = false;
|
||||
this.initialized = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialisierung
|
||||
*/
|
||||
async init() {
|
||||
if (this.initialized) return;
|
||||
|
||||
this.bindElements();
|
||||
this.bindEvents();
|
||||
await this.loadNotifications();
|
||||
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* DOM-Elemente binden
|
||||
*/
|
||||
bindElements() {
|
||||
this.bellBtn = document.getElementById('notification-btn');
|
||||
this.badge = document.getElementById('notification-badge');
|
||||
this.dropdown = document.getElementById('notification-dropdown');
|
||||
this.list = document.getElementById('notification-list');
|
||||
this.emptyState = document.getElementById('notification-empty');
|
||||
this.markAllBtn = document.getElementById('btn-mark-all-read');
|
||||
this.bellContainer = document.getElementById('notification-bell');
|
||||
}
|
||||
|
||||
/**
|
||||
* Event-Listener binden
|
||||
*/
|
||||
bindEvents() {
|
||||
// Toggle Dropdown
|
||||
this.bellBtn?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.toggleDropdown();
|
||||
});
|
||||
|
||||
// Klick außerhalb schließt Dropdown
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!this.bellContainer?.contains(e.target)) {
|
||||
this.closeDropdown();
|
||||
}
|
||||
});
|
||||
|
||||
// Alle als gelesen markieren
|
||||
this.markAllBtn?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.markAllAsRead();
|
||||
});
|
||||
|
||||
// Klicks in der Liste
|
||||
this.list?.addEventListener('click', (e) => {
|
||||
const deleteBtn = e.target.closest('.notification-delete');
|
||||
if (deleteBtn) {
|
||||
e.stopPropagation();
|
||||
const id = parseInt(deleteBtn.dataset.delete);
|
||||
this.deleteNotification(id);
|
||||
return;
|
||||
}
|
||||
|
||||
const item = e.target.closest('.notification-item');
|
||||
if (item) {
|
||||
this.handleItemClick(item);
|
||||
}
|
||||
});
|
||||
|
||||
// WebSocket Events
|
||||
window.addEventListener('notification:new', (e) => {
|
||||
this.addNotification(e.detail.notification);
|
||||
});
|
||||
|
||||
window.addEventListener('notification:count', (e) => {
|
||||
this.updateBadge(e.detail.count);
|
||||
});
|
||||
|
||||
window.addEventListener('notification:deleted', (e) => {
|
||||
this.removeNotification(e.detail.notificationId);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Benachrichtigungen vom Server laden
|
||||
*/
|
||||
async loadNotifications() {
|
||||
try {
|
||||
const data = await api.getNotifications();
|
||||
this.notifications = data.notifications || [];
|
||||
this.unreadCount = data.unreadCount || 0;
|
||||
this.render();
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der Benachrichtigungen:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Alles rendern
|
||||
*/
|
||||
render() {
|
||||
this.updateBadge(this.unreadCount);
|
||||
this.renderList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Badge aktualisieren
|
||||
*/
|
||||
updateBadge(count) {
|
||||
this.unreadCount = count;
|
||||
|
||||
if (count > 0) {
|
||||
this.badge.textContent = count > 99 ? '99+' : count;
|
||||
this.badge.classList.remove('hidden');
|
||||
this.bellContainer?.classList.add('has-notifications');
|
||||
} else {
|
||||
this.badge.classList.add('hidden');
|
||||
this.bellContainer?.classList.remove('has-notifications');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Liste rendern
|
||||
*/
|
||||
renderList() {
|
||||
if (!this.notifications || this.notifications.length === 0) {
|
||||
this.list?.classList.add('hidden');
|
||||
this.emptyState?.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
this.list?.classList.remove('hidden');
|
||||
this.emptyState?.classList.add('hidden');
|
||||
|
||||
this.list.innerHTML = this.notifications.map(n => this.renderItem(n)).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Einzelnes Item rendern
|
||||
*/
|
||||
renderItem(notification) {
|
||||
const timeAgo = this.formatTimeAgo(notification.createdAt);
|
||||
const iconClass = this.getIconClass(notification.type);
|
||||
const icon = this.getIcon(notification.type);
|
||||
|
||||
return `
|
||||
<div class="notification-item ${notification.isRead ? '' : 'unread'} ${notification.isPersistent ? 'persistent' : ''}"
|
||||
data-id="${notification.id}"
|
||||
data-task-id="${notification.taskId || ''}"
|
||||
data-proposal-id="${notification.proposalId || ''}">
|
||||
<div class="notification-type-icon ${iconClass}">
|
||||
${icon}
|
||||
</div>
|
||||
<div class="notification-content">
|
||||
<div class="notification-title">${this.escapeHtml(notification.title)}</div>
|
||||
${notification.message ? `<div class="notification-message">${this.escapeHtml(notification.message)}</div>` : ''}
|
||||
<div class="notification-time">${timeAgo}</div>
|
||||
</div>
|
||||
${!notification.isPersistent ? `
|
||||
<div class="notification-actions">
|
||||
<button class="notification-delete" title="Löschen" data-delete="${notification.id}">
|
||||
<svg viewBox="0 0 24 24"><path d="M18 6L6 18M6 6l12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Icon-Klasse basierend auf Typ
|
||||
*/
|
||||
getIconClass(type) {
|
||||
if (type.startsWith('task:assigned') || type.startsWith('task:unassigned') || type.startsWith('task:due')) {
|
||||
return 'task';
|
||||
}
|
||||
if (type.startsWith('task:completed')) {
|
||||
return 'completed';
|
||||
}
|
||||
if (type.startsWith('task:priority')) {
|
||||
return 'priority';
|
||||
}
|
||||
if (type.startsWith('comment:mention')) {
|
||||
return 'mention';
|
||||
}
|
||||
if (type.startsWith('comment:')) {
|
||||
return 'comment';
|
||||
}
|
||||
if (type.startsWith('approval:')) {
|
||||
return 'approval';
|
||||
}
|
||||
return 'task';
|
||||
}
|
||||
|
||||
/**
|
||||
* Icon SVG basierend auf Typ
|
||||
*/
|
||||
getIcon(type) {
|
||||
if (type.startsWith('task:assigned')) {
|
||||
return '<svg viewBox="0 0 24 24"><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" stroke="currentColor" stroke-width="2" fill="none"/><circle cx="8.5" cy="7" r="4" stroke="currentColor" stroke-width="2" fill="none"/><path d="M20 8v6M23 11h-6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>';
|
||||
}
|
||||
if (type.startsWith('task:unassigned')) {
|
||||
return '<svg viewBox="0 0 24 24"><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" stroke="currentColor" stroke-width="2" fill="none"/><circle cx="8.5" cy="7" r="4" stroke="currentColor" stroke-width="2" fill="none"/><path d="M23 11h-6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>';
|
||||
}
|
||||
if (type.startsWith('task:completed')) {
|
||||
return '<svg viewBox="0 0 24 24"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" stroke="currentColor" stroke-width="2" fill="none"/><path d="M22 4 12 14.01l-3-3" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
||||
}
|
||||
if (type.startsWith('task:due')) {
|
||||
return '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" fill="none"/><path d="M12 6v6l4 2" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>';
|
||||
}
|
||||
if (type.startsWith('task:priority')) {
|
||||
return '<svg viewBox="0 0 24 24"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" stroke="currentColor" stroke-width="2" fill="none"/><path d="M12 9v4M12 17h.01" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>';
|
||||
}
|
||||
if (type.startsWith('comment:mention')) {
|
||||
return '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="4" stroke="currentColor" stroke-width="2" fill="none"/><path d="M16 8v5a3 3 0 0 0 6 0v-1a10 10 0 1 0-3.92 7.94" stroke="currentColor" stroke-width="2" fill="none"/></svg>';
|
||||
}
|
||||
if (type.startsWith('comment:')) {
|
||||
return '<svg viewBox="0 0 24 24"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" stroke="currentColor" stroke-width="2" fill="none"/></svg>';
|
||||
}
|
||||
if (type.startsWith('approval:pending')) {
|
||||
return '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" fill="none"/><path d="M12 8v4M12 16h.01" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>';
|
||||
}
|
||||
if (type.startsWith('approval:granted')) {
|
||||
return '<svg viewBox="0 0 24 24"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" stroke="currentColor" stroke-width="2" fill="none"/><path d="M22 4 12 14.01l-3-3" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
||||
}
|
||||
if (type.startsWith('approval:rejected')) {
|
||||
return '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" fill="none"/><path d="M15 9l-6 6M9 9l6 6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>';
|
||||
}
|
||||
// Default
|
||||
return '<svg viewBox="0 0 24 24"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" stroke="currentColor" stroke-width="2" fill="none"/></svg>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Zeit-Formatierung
|
||||
*/
|
||||
formatTimeAgo(dateString) {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffMs = now - date;
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return 'Gerade eben';
|
||||
if (diffMins < 60) return `Vor ${diffMins} Minute${diffMins === 1 ? '' : 'n'}`;
|
||||
if (diffHours < 24) return `Vor ${diffHours} Stunde${diffHours === 1 ? '' : 'n'}`;
|
||||
if (diffDays < 7) return `Vor ${diffDays} Tag${diffDays === 1 ? '' : 'en'}`;
|
||||
|
||||
return date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||
}
|
||||
|
||||
/**
|
||||
* HTML escapen
|
||||
*/
|
||||
escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dropdown öffnen/schließen
|
||||
*/
|
||||
toggleDropdown() {
|
||||
if (this.isDropdownOpen) {
|
||||
this.closeDropdown();
|
||||
} else {
|
||||
this.openDropdown();
|
||||
}
|
||||
}
|
||||
|
||||
openDropdown() {
|
||||
this.dropdown?.classList.remove('hidden');
|
||||
this.isDropdownOpen = true;
|
||||
// Ungelesene als gelesen markieren wenn Dropdown geöffnet
|
||||
this.markVisibleAsRead();
|
||||
}
|
||||
|
||||
closeDropdown() {
|
||||
this.dropdown?.classList.add('hidden');
|
||||
this.isDropdownOpen = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sichtbare Benachrichtigungen als gelesen markieren
|
||||
*/
|
||||
async markVisibleAsRead() {
|
||||
const unreadItems = this.notifications.filter(n => !n.isRead && !n.isPersistent);
|
||||
|
||||
for (const notification of unreadItems) {
|
||||
try {
|
||||
await api.markNotificationRead(notification.id);
|
||||
notification.isRead = true;
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Markieren als gelesen:', error);
|
||||
}
|
||||
}
|
||||
|
||||
this.renderList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Alle als gelesen markieren
|
||||
*/
|
||||
async markAllAsRead() {
|
||||
try {
|
||||
await api.markAllNotificationsRead();
|
||||
this.notifications.forEach(n => {
|
||||
if (!n.isPersistent) n.isRead = true;
|
||||
});
|
||||
this.unreadCount = this.notifications.filter(n => !n.isRead).length;
|
||||
this.updateBadge(this.unreadCount);
|
||||
this.renderList();
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Markieren aller als gelesen:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Benachrichtigung löschen
|
||||
*/
|
||||
async deleteNotification(id) {
|
||||
try {
|
||||
const result = await api.deleteNotification(id);
|
||||
this.notifications = this.notifications.filter(n => n.id !== id);
|
||||
this.unreadCount = result.unreadCount;
|
||||
this.updateBadge(this.unreadCount);
|
||||
this.renderList();
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Löschen der Benachrichtigung:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Neue Benachrichtigung hinzufügen
|
||||
*/
|
||||
addNotification(notification) {
|
||||
// Am Anfang der Liste hinzufügen
|
||||
this.notifications.unshift(notification);
|
||||
this.unreadCount++;
|
||||
this.updateBadge(this.unreadCount);
|
||||
this.renderList();
|
||||
|
||||
// Toast anzeigen wenn Dropdown geschlossen
|
||||
if (!this.isDropdownOpen) {
|
||||
this.showToast(notification.title, notification.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Benachrichtigung entfernen (WebSocket)
|
||||
*/
|
||||
removeNotification(id) {
|
||||
this.notifications = this.notifications.filter(n => n.id !== id);
|
||||
this.renderList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Toast-Benachrichtigung anzeigen
|
||||
*/
|
||||
showToast(title, message) {
|
||||
// Einfache Toast-Implementation
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'notification-toast';
|
||||
toast.innerHTML = `
|
||||
<strong>${this.escapeHtml(title)}</strong>
|
||||
${message ? `<p>${this.escapeHtml(message)}</p>` : ''}
|
||||
`;
|
||||
toast.style.cssText = `
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
padding: 16px 20px;
|
||||
background: var(--bg-card, #fff);
|
||||
border: 1px solid var(--border-default, #e2e8f0);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 10px 25px rgba(0,0,0,0.15);
|
||||
z-index: 9999;
|
||||
max-width: 320px;
|
||||
animation: slideIn 0.3s ease;
|
||||
`;
|
||||
|
||||
// Animation
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
@keyframes slideIn {
|
||||
from { transform: translateX(100%); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
|
||||
document.body.appendChild(toast);
|
||||
|
||||
// Nach 4 Sekunden entfernen
|
||||
setTimeout(() => {
|
||||
toast.style.animation = 'slideIn 0.3s ease reverse';
|
||||
setTimeout(() => {
|
||||
toast.remove();
|
||||
style.remove();
|
||||
}, 300);
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Item-Klick behandeln
|
||||
*/
|
||||
handleItemClick(item) {
|
||||
const taskId = item.dataset.taskId;
|
||||
const proposalId = item.dataset.proposalId;
|
||||
|
||||
if (taskId) {
|
||||
// Zur Aufgabe navigieren
|
||||
this.closeDropdown();
|
||||
window.dispatchEvent(new CustomEvent('notification:open-task', { detail: { taskId: parseInt(taskId) } }));
|
||||
} else if (proposalId) {
|
||||
// Zum Genehmigung-Tab wechseln
|
||||
this.closeDropdown();
|
||||
window.dispatchEvent(new CustomEvent('notification:open-proposal', { detail: { proposalId: parseInt(proposalId) } }));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset
|
||||
*/
|
||||
reset() {
|
||||
this.notifications = [];
|
||||
this.unreadCount = 0;
|
||||
this.isDropdownOpen = false;
|
||||
this.closeDropdown();
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton-Instanz
|
||||
const notificationManager = new NotificationManager();
|
||||
|
||||
export { notificationManager };
|
||||
export default notificationManager;
|
||||
501
frontend/js/offline.js
Normale Datei
501
frontend/js/offline.js
Normale Datei
@ -0,0 +1,501 @@
|
||||
/**
|
||||
* TASKMATE - Offline Module
|
||||
* ==========================
|
||||
* IndexedDB storage and offline support
|
||||
*/
|
||||
|
||||
import store from './store.js';
|
||||
import api from './api.js';
|
||||
|
||||
const DB_NAME = 'TaskMateDB';
|
||||
const DB_VERSION = 1;
|
||||
|
||||
class OfflineManager {
|
||||
constructor() {
|
||||
this.db = null;
|
||||
this.isReady = false;
|
||||
this.pendingSync = [];
|
||||
}
|
||||
|
||||
// Initialize IndexedDB
|
||||
async init() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
|
||||
request.onerror = () => {
|
||||
console.error('[Offline] Failed to open database');
|
||||
reject(request.error);
|
||||
};
|
||||
|
||||
request.onsuccess = () => {
|
||||
this.db = request.result;
|
||||
this.isReady = true;
|
||||
console.log('[Offline] Database ready');
|
||||
resolve();
|
||||
};
|
||||
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = event.target.result;
|
||||
|
||||
// Projects store
|
||||
if (!db.objectStoreNames.contains('projects')) {
|
||||
const projectStore = db.createObjectStore('projects', { keyPath: 'id' });
|
||||
projectStore.createIndex('name', 'name', { unique: false });
|
||||
}
|
||||
|
||||
// Columns store
|
||||
if (!db.objectStoreNames.contains('columns')) {
|
||||
const columnStore = db.createObjectStore('columns', { keyPath: 'id' });
|
||||
columnStore.createIndex('project_id', 'project_id', { unique: false });
|
||||
columnStore.createIndex('position', 'position', { unique: false });
|
||||
}
|
||||
|
||||
// Tasks store
|
||||
if (!db.objectStoreNames.contains('tasks')) {
|
||||
const taskStore = db.createObjectStore('tasks', { keyPath: 'id' });
|
||||
taskStore.createIndex('project_id', 'project_id', { unique: false });
|
||||
taskStore.createIndex('column_id', 'column_id', { unique: false });
|
||||
taskStore.createIndex('assignee_id', 'assignee_id', { unique: false });
|
||||
taskStore.createIndex('due_date', 'due_date', { unique: false });
|
||||
}
|
||||
|
||||
// Labels store
|
||||
if (!db.objectStoreNames.contains('labels')) {
|
||||
const labelStore = db.createObjectStore('labels', { keyPath: 'id' });
|
||||
labelStore.createIndex('project_id', 'project_id', { unique: false });
|
||||
}
|
||||
|
||||
// Pending operations store (for offline sync)
|
||||
if (!db.objectStoreNames.contains('pending_operations')) {
|
||||
const pendingStore = db.createObjectStore('pending_operations', {
|
||||
keyPath: 'id',
|
||||
autoIncrement: true
|
||||
});
|
||||
pendingStore.createIndex('timestamp', 'timestamp', { unique: false });
|
||||
pendingStore.createIndex('type', 'type', { unique: false });
|
||||
}
|
||||
|
||||
// Cache metadata store
|
||||
if (!db.objectStoreNames.contains('cache_meta')) {
|
||||
db.createObjectStore('cache_meta', { keyPath: 'key' });
|
||||
}
|
||||
|
||||
console.log('[Offline] Database schema created');
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Generic CRUD operations
|
||||
async put(storeName, data) {
|
||||
if (!this.db) return;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this.db.transaction(storeName, 'readwrite');
|
||||
const store = transaction.objectStore(storeName);
|
||||
const request = store.put(data);
|
||||
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async get(storeName, key) {
|
||||
if (!this.db) return null;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this.db.transaction(storeName, 'readonly');
|
||||
const store = transaction.objectStore(storeName);
|
||||
const request = store.get(key);
|
||||
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async getAll(storeName, indexName = null, query = null) {
|
||||
if (!this.db) return [];
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this.db.transaction(storeName, 'readonly');
|
||||
const store = transaction.objectStore(storeName);
|
||||
const target = indexName ? store.index(indexName) : store;
|
||||
const request = query ? target.getAll(query) : target.getAll();
|
||||
|
||||
request.onsuccess = () => resolve(request.result || []);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async delete(storeName, key) {
|
||||
if (!this.db) return;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this.db.transaction(storeName, 'readwrite');
|
||||
const store = transaction.objectStore(storeName);
|
||||
const request = store.delete(key);
|
||||
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async clear(storeName) {
|
||||
if (!this.db) return;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this.db.transaction(storeName, 'readwrite');
|
||||
const store = transaction.objectStore(storeName);
|
||||
const request = store.clear();
|
||||
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
// =====================
|
||||
// DATA CACHING
|
||||
// =====================
|
||||
|
||||
async cacheProjects(projects) {
|
||||
await this.clear('projects');
|
||||
for (const project of projects) {
|
||||
await this.put('projects', project);
|
||||
}
|
||||
await this.setCacheMeta('projects', Date.now());
|
||||
}
|
||||
|
||||
async getCachedProjects() {
|
||||
return this.getAll('projects');
|
||||
}
|
||||
|
||||
async cacheColumns(projectId, columns) {
|
||||
// Clear existing columns for project
|
||||
const existing = await this.getAll('columns', 'project_id', projectId);
|
||||
for (const col of existing) {
|
||||
await this.delete('columns', col.id);
|
||||
}
|
||||
|
||||
// Store new columns
|
||||
for (const column of columns) {
|
||||
await this.put('columns', { ...column, project_id: projectId });
|
||||
}
|
||||
await this.setCacheMeta(`columns_${projectId}`, Date.now());
|
||||
}
|
||||
|
||||
async getCachedColumns(projectId) {
|
||||
return this.getAll('columns', 'project_id', projectId);
|
||||
}
|
||||
|
||||
async cacheTasks(projectId, tasks) {
|
||||
// Clear existing tasks for project
|
||||
const existing = await this.getAll('tasks', 'project_id', projectId);
|
||||
for (const task of existing) {
|
||||
await this.delete('tasks', task.id);
|
||||
}
|
||||
|
||||
// Store new tasks
|
||||
for (const task of tasks) {
|
||||
await this.put('tasks', { ...task, project_id: projectId });
|
||||
}
|
||||
await this.setCacheMeta(`tasks_${projectId}`, Date.now());
|
||||
}
|
||||
|
||||
async getCachedTasks(projectId) {
|
||||
return this.getAll('tasks', 'project_id', projectId);
|
||||
}
|
||||
|
||||
async cacheLabels(projectId, labels) {
|
||||
const existing = await this.getAll('labels', 'project_id', projectId);
|
||||
for (const label of existing) {
|
||||
await this.delete('labels', label.id);
|
||||
}
|
||||
|
||||
for (const label of labels) {
|
||||
await this.put('labels', { ...label, project_id: projectId });
|
||||
}
|
||||
await this.setCacheMeta(`labels_${projectId}`, Date.now());
|
||||
}
|
||||
|
||||
async getCachedLabels(projectId) {
|
||||
return this.getAll('labels', 'project_id', projectId);
|
||||
}
|
||||
|
||||
// Cache metadata
|
||||
async setCacheMeta(key, timestamp) {
|
||||
await this.put('cache_meta', { key, timestamp });
|
||||
}
|
||||
|
||||
async getCacheMeta(key) {
|
||||
return this.get('cache_meta', key);
|
||||
}
|
||||
|
||||
async isCacheValid(key, maxAge = 5 * 60 * 1000) {
|
||||
const meta = await this.getCacheMeta(key);
|
||||
if (!meta) return false;
|
||||
|
||||
return Date.now() - meta.timestamp < maxAge;
|
||||
}
|
||||
|
||||
// =====================
|
||||
// OFFLINE OPERATIONS
|
||||
// =====================
|
||||
|
||||
async queueOperation(operation) {
|
||||
const pendingOp = {
|
||||
...operation,
|
||||
timestamp: Date.now(),
|
||||
synced: false
|
||||
};
|
||||
|
||||
await this.put('pending_operations', pendingOp);
|
||||
store.setSyncStatus('offline');
|
||||
|
||||
return pendingOp;
|
||||
}
|
||||
|
||||
async getPendingOperations() {
|
||||
return this.getAll('pending_operations');
|
||||
}
|
||||
|
||||
async markOperationSynced(operationId) {
|
||||
await this.delete('pending_operations', operationId);
|
||||
}
|
||||
|
||||
async clearPendingOperations() {
|
||||
await this.clear('pending_operations');
|
||||
}
|
||||
|
||||
// Sync pending operations with server
|
||||
async syncPendingOperations() {
|
||||
if (!navigator.onLine) {
|
||||
return { success: false, reason: 'offline' };
|
||||
}
|
||||
|
||||
const operations = await this.getPendingOperations();
|
||||
|
||||
if (operations.length === 0) {
|
||||
return { success: true, synced: 0 };
|
||||
}
|
||||
|
||||
console.log(`[Offline] Syncing ${operations.length} pending operations`);
|
||||
store.setSyncStatus('syncing');
|
||||
|
||||
let syncedCount = 0;
|
||||
const errors = [];
|
||||
|
||||
// Sort by timestamp
|
||||
operations.sort((a, b) => a.timestamp - b.timestamp);
|
||||
|
||||
for (const op of operations) {
|
||||
try {
|
||||
await this.executePendingOperation(op);
|
||||
await this.markOperationSynced(op.id);
|
||||
syncedCount++;
|
||||
} catch (error) {
|
||||
console.error(`[Offline] Failed to sync operation:`, op, error);
|
||||
errors.push({ operation: op, error: error.message });
|
||||
|
||||
// Stop on auth errors
|
||||
if (error.status === 401) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const remaining = await this.getPendingOperations();
|
||||
store.setSyncStatus(remaining.length > 0 ? 'offline' : 'synced');
|
||||
|
||||
return {
|
||||
success: errors.length === 0,
|
||||
synced: syncedCount,
|
||||
errors
|
||||
};
|
||||
}
|
||||
|
||||
async executePendingOperation(op) {
|
||||
const projectId = op.projectId;
|
||||
|
||||
switch (op.type) {
|
||||
case 'task:create':
|
||||
const newTask = await api.createTask(projectId, op.data);
|
||||
// Update local ID mapping
|
||||
if (op.tempId) {
|
||||
await this.updateTaskId(op.tempId, newTask.id);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'task:update':
|
||||
await api.updateTask(projectId, op.taskId, op.data);
|
||||
break;
|
||||
|
||||
case 'task:delete':
|
||||
await api.deleteTask(projectId, op.taskId);
|
||||
break;
|
||||
|
||||
case 'task:move':
|
||||
await api.moveTask(projectId, op.taskId, op.columnId, op.position);
|
||||
break;
|
||||
|
||||
case 'column:create':
|
||||
const newColumn = await api.createColumn(projectId, op.data);
|
||||
if (op.tempId) {
|
||||
await this.updateColumnId(op.tempId, newColumn.id);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'column:update':
|
||||
await api.updateColumn(projectId, op.columnId, op.data);
|
||||
break;
|
||||
|
||||
case 'column:delete':
|
||||
await api.deleteColumn(projectId, op.columnId);
|
||||
break;
|
||||
|
||||
case 'subtask:create':
|
||||
await api.createSubtask(projectId, op.taskId, op.data);
|
||||
break;
|
||||
|
||||
case 'subtask:update':
|
||||
await api.updateSubtask(projectId, op.taskId, op.subtaskId, op.data);
|
||||
break;
|
||||
|
||||
case 'comment:create':
|
||||
await api.createComment(projectId, op.taskId, op.data);
|
||||
break;
|
||||
|
||||
default:
|
||||
console.warn(`[Offline] Unknown operation type: ${op.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
async updateTaskId(tempId, realId) {
|
||||
// Update in cache
|
||||
const task = await this.get('tasks', tempId);
|
||||
if (task) {
|
||||
await this.delete('tasks', tempId);
|
||||
await this.put('tasks', { ...task, id: realId });
|
||||
}
|
||||
|
||||
// Update pending operations that reference this task
|
||||
const pending = await this.getPendingOperations();
|
||||
for (const op of pending) {
|
||||
if (op.taskId === tempId) {
|
||||
await this.put('pending_operations', { ...op, taskId: realId });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async updateColumnId(tempId, realId) {
|
||||
const column = await this.get('columns', tempId);
|
||||
if (column) {
|
||||
await this.delete('columns', tempId);
|
||||
await this.put('columns', { ...column, id: realId });
|
||||
}
|
||||
|
||||
// Update tasks in this column
|
||||
const tasks = await this.getAll('tasks');
|
||||
for (const task of tasks) {
|
||||
if (task.column_id === tempId) {
|
||||
await this.put('tasks', { ...task, column_id: realId });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =====================
|
||||
// OFFLINE DATA LOADING
|
||||
// =====================
|
||||
|
||||
async loadOfflineData() {
|
||||
const projectId = store.get('currentProjectId');
|
||||
if (!projectId) return;
|
||||
|
||||
console.log('[Offline] Loading cached data');
|
||||
|
||||
try {
|
||||
// Load from cache
|
||||
const [columns, tasks, labels] = await Promise.all([
|
||||
this.getCachedColumns(projectId),
|
||||
this.getCachedTasks(projectId),
|
||||
this.getCachedLabels(projectId)
|
||||
]);
|
||||
|
||||
store.setColumns(columns);
|
||||
store.setTasks(tasks);
|
||||
store.setLabels(labels);
|
||||
|
||||
console.log('[Offline] Loaded cached data:', {
|
||||
columns: columns.length,
|
||||
tasks: tasks.length,
|
||||
labels: labels.length
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Offline] Failed to load cached data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// =====================
|
||||
// HELPERS
|
||||
// =====================
|
||||
|
||||
hasPendingOperations() {
|
||||
return this.getPendingOperations().then(ops => ops.length > 0);
|
||||
}
|
||||
|
||||
async getPendingOperationCount() {
|
||||
const ops = await this.getPendingOperations();
|
||||
return ops.length;
|
||||
}
|
||||
|
||||
// Clear all cached data
|
||||
async clearAllData() {
|
||||
const stores = ['projects', 'columns', 'tasks', 'labels', 'pending_operations', 'cache_meta'];
|
||||
|
||||
for (const storeName of stores) {
|
||||
await this.clear(storeName);
|
||||
}
|
||||
|
||||
console.log('[Offline] Cleared all cached data');
|
||||
}
|
||||
}
|
||||
|
||||
// Create singleton instance
|
||||
const offlineManager = new OfflineManager();
|
||||
|
||||
// Initialize on load
|
||||
offlineManager.init().catch(console.error);
|
||||
|
||||
// Listen for online event to sync
|
||||
window.addEventListener('online', async () => {
|
||||
console.log('[Offline] Back online, starting sync');
|
||||
await offlineManager.syncPendingOperations();
|
||||
});
|
||||
|
||||
// Subscribe to store changes to cache data
|
||||
store.subscribe('columns', async (columns) => {
|
||||
const projectId = store.get('currentProjectId');
|
||||
if (projectId && columns.length > 0) {
|
||||
await offlineManager.cacheColumns(projectId, columns);
|
||||
}
|
||||
});
|
||||
|
||||
store.subscribe('tasks', async (tasks) => {
|
||||
const projectId = store.get('currentProjectId');
|
||||
if (projectId && tasks.length > 0) {
|
||||
await offlineManager.cacheTasks(projectId, tasks);
|
||||
}
|
||||
});
|
||||
|
||||
store.subscribe('labels', async (labels) => {
|
||||
const projectId = store.get('currentProjectId');
|
||||
if (projectId && labels.length > 0) {
|
||||
await offlineManager.cacheLabels(projectId, labels);
|
||||
}
|
||||
});
|
||||
|
||||
store.subscribe('projects', async (projects) => {
|
||||
if (projects.length > 0) {
|
||||
await offlineManager.cacheProjects(projects);
|
||||
}
|
||||
});
|
||||
|
||||
export default offlineManager;
|
||||
469
frontend/js/proposals.js
Normale Datei
469
frontend/js/proposals.js
Normale Datei
@ -0,0 +1,469 @@
|
||||
/**
|
||||
* TASKMATE - Proposals Manager
|
||||
* ============================
|
||||
* Genehmigungen (projektbezogen)
|
||||
*/
|
||||
|
||||
import api from './api.js';
|
||||
import { $, $$ } from './utils.js';
|
||||
import authManager from './auth.js';
|
||||
import store from './store.js';
|
||||
|
||||
class ProposalsManager {
|
||||
constructor() {
|
||||
this.proposals = [];
|
||||
this.currentSort = 'date';
|
||||
this.showArchived = false;
|
||||
this.searchQuery = '';
|
||||
this.allTasks = [];
|
||||
this.initialized = false;
|
||||
}
|
||||
|
||||
async init() {
|
||||
console.log('[Proposals] init() called, initialized:', this.initialized);
|
||||
|
||||
if (this.initialized) {
|
||||
await this.loadProposals();
|
||||
return;
|
||||
}
|
||||
|
||||
// DOM Elements - erst bei init() laden
|
||||
this.proposalsView = $('#view-proposals');
|
||||
this.proposalsList = $('#proposals-list');
|
||||
this.proposalsEmpty = $('#proposals-empty');
|
||||
this.sortSelect = $('#proposals-sort');
|
||||
this.newProposalBtn = $('#btn-new-proposal');
|
||||
this.archiveBtn = $('#btn-show-proposals-archive');
|
||||
this.proposalsTitle = $('#proposals-title');
|
||||
|
||||
console.log('[Proposals] newProposalBtn found:', !!this.newProposalBtn);
|
||||
|
||||
// Modal Elements
|
||||
this.proposalModal = $('#proposal-modal');
|
||||
this.proposalForm = $('#proposal-form');
|
||||
this.proposalTitle = $('#proposal-title');
|
||||
this.proposalDescription = $('#proposal-description');
|
||||
this.proposalTask = $('#proposal-task');
|
||||
|
||||
this.bindEvents();
|
||||
this.initialized = true;
|
||||
console.log('[Proposals] Initialization complete');
|
||||
|
||||
await this.loadProposals();
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
console.log('[Proposals] bindEvents() called');
|
||||
|
||||
// Sort Change
|
||||
this.sortSelect?.addEventListener('change', () => {
|
||||
this.currentSort = this.sortSelect.value;
|
||||
this.loadProposals();
|
||||
});
|
||||
|
||||
// Archive Toggle Button
|
||||
this.archiveBtn?.addEventListener('click', () => {
|
||||
this.showArchived = !this.showArchived;
|
||||
this.updateArchiveButton();
|
||||
this.loadProposals();
|
||||
});
|
||||
|
||||
// New Proposal Button
|
||||
if (this.newProposalBtn) {
|
||||
console.log('[Proposals] Binding click event to newProposalBtn');
|
||||
this.newProposalBtn.addEventListener('click', () => {
|
||||
console.log('[Proposals] Button clicked!');
|
||||
this.openNewProposalModal();
|
||||
});
|
||||
} else {
|
||||
console.error('[Proposals] newProposalBtn not found!');
|
||||
}
|
||||
|
||||
// Proposal Form Submit
|
||||
this.proposalForm?.addEventListener('submit', (e) => this.handleProposalSubmit(e));
|
||||
|
||||
// Modal close buttons
|
||||
this.proposalModal?.querySelectorAll('[data-close-modal]').forEach(btn => {
|
||||
btn.addEventListener('click', () => this.closeModal());
|
||||
});
|
||||
}
|
||||
|
||||
updateArchiveButton() {
|
||||
if (this.archiveBtn) {
|
||||
this.archiveBtn.textContent = this.showArchived ? 'Aktive anzeigen' : 'Archiv anzeigen';
|
||||
}
|
||||
if (this.proposalsTitle) {
|
||||
this.proposalsTitle.textContent = this.showArchived ? 'Archiv' : 'Genehmigungen';
|
||||
}
|
||||
}
|
||||
|
||||
resetToActiveView() {
|
||||
this.showArchived = false;
|
||||
this.searchQuery = '';
|
||||
this.updateArchiveButton();
|
||||
this.loadProposals();
|
||||
}
|
||||
|
||||
setSearchQuery(query) {
|
||||
this.searchQuery = query.toLowerCase().trim();
|
||||
this.render();
|
||||
}
|
||||
|
||||
getFilteredProposals() {
|
||||
if (!this.searchQuery) {
|
||||
return this.proposals;
|
||||
}
|
||||
return this.proposals.filter(proposal => {
|
||||
const titleMatch = proposal.title?.toLowerCase().includes(this.searchQuery);
|
||||
const descriptionMatch = proposal.description?.toLowerCase().includes(this.searchQuery);
|
||||
return titleMatch || descriptionMatch;
|
||||
});
|
||||
}
|
||||
|
||||
getCurrentProjectId() {
|
||||
// Direkt die currentProjectId aus dem Store holen
|
||||
return store.get('currentProjectId') || null;
|
||||
}
|
||||
|
||||
async loadProposals() {
|
||||
try {
|
||||
const projectId = this.getCurrentProjectId();
|
||||
this.proposals = await api.getProposals(this.currentSort, this.showArchived, projectId);
|
||||
this.render();
|
||||
} catch (error) {
|
||||
console.error('Error loading proposals:', error);
|
||||
this.showToast('Fehler beim Laden der Genehmigungen', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async loadTasks() {
|
||||
try {
|
||||
this.allTasks = await api.getAllTasks();
|
||||
this.populateTaskDropdown();
|
||||
} catch (error) {
|
||||
console.error('Error loading tasks:', error);
|
||||
}
|
||||
}
|
||||
|
||||
populateTaskDropdown() {
|
||||
if (!this.proposalTask) return;
|
||||
|
||||
const currentProjectId = this.getCurrentProjectId();
|
||||
|
||||
// Reset dropdown
|
||||
this.proposalTask.innerHTML = '<option value="">Keine Aufgabe</option>';
|
||||
|
||||
// Nur Aufgaben des aktuellen Projekts anzeigen
|
||||
const projectTasks = this.allTasks.filter(task => task.project_id === currentProjectId);
|
||||
|
||||
projectTasks.forEach(task => {
|
||||
const option = document.createElement('option');
|
||||
option.value = task.id;
|
||||
option.textContent = task.title;
|
||||
this.proposalTask.appendChild(option);
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.proposalsList) return;
|
||||
|
||||
const filteredProposals = this.getFilteredProposals();
|
||||
|
||||
if (filteredProposals.length === 0) {
|
||||
this.proposalsList.classList.add('hidden');
|
||||
this.proposalsEmpty?.classList.remove('hidden');
|
||||
// Update empty message based on search
|
||||
if (this.proposalsEmpty) {
|
||||
const h3 = this.proposalsEmpty.querySelector('h3');
|
||||
const p = this.proposalsEmpty.querySelector('p');
|
||||
if (this.searchQuery) {
|
||||
if (h3) h3.textContent = 'Keine Treffer';
|
||||
if (p) p.textContent = 'Keine Genehmigungen entsprechen der Suche.';
|
||||
} else {
|
||||
if (h3) h3.textContent = 'Keine Genehmigungen vorhanden';
|
||||
if (p) p.textContent = 'Erstellen Sie die erste Genehmigung!';
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.proposalsEmpty?.classList.add('hidden');
|
||||
this.proposalsList.classList.remove('hidden');
|
||||
|
||||
const currentUserId = authManager.getUser()?.id;
|
||||
const canApprove = authManager.hasPermission('genehmigung');
|
||||
|
||||
this.proposalsList.innerHTML = filteredProposals.map(proposal =>
|
||||
this.renderProposalCard(proposal, currentUserId, canApprove)
|
||||
).join('');
|
||||
|
||||
// Bind event listeners
|
||||
this.bindProposalEvents();
|
||||
}
|
||||
|
||||
renderProposalCard(proposal, currentUserId, canApprove) {
|
||||
const isOwn = proposal.created_by === currentUserId;
|
||||
const initial = (proposal.created_by_name || 'U').charAt(0).toUpperCase();
|
||||
const dateStr = this.formatDate(proposal.created_at);
|
||||
const isArchived = proposal.archived === 1;
|
||||
|
||||
return `
|
||||
<div class="proposal-card ${proposal.approved ? 'approved' : ''} ${isArchived ? 'archived' : ''}" data-proposal-id="${proposal.id}">
|
||||
<div class="proposal-header">
|
||||
<h3 class="proposal-title">${this.escapeHtml(proposal.title)}</h3>
|
||||
<div class="proposal-badges">
|
||||
${isArchived ? `
|
||||
<span class="proposal-archived-badge">
|
||||
<svg viewBox="0 0 24 24"><path d="M21 8v13H3V8M1 3h22v5H1zM10 12h4" stroke="currentColor" stroke-width="2" fill="none"/></svg>
|
||||
Archiviert
|
||||
</span>
|
||||
` : ''}
|
||||
${proposal.approved ? `
|
||||
<span class="proposal-approved-badge">
|
||||
<svg viewBox="0 0 24 24"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" stroke="currentColor" stroke-width="2" fill="none"/><path d="m22 4-10 10-3-3" stroke="currentColor" stroke-width="2" fill="none"/></svg>
|
||||
Genehmigt
|
||||
</span>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
${proposal.description ? `
|
||||
<p class="proposal-description">${this.escapeHtml(proposal.description)}</p>
|
||||
` : ''}
|
||||
${proposal.task_title ? `
|
||||
<div class="proposal-linked-task">
|
||||
<svg viewBox="0 0 24 24"><path d="M9 11l3 3L22 4" stroke="currentColor" stroke-width="2" fill="none"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" stroke="currentColor" stroke-width="2" fill="none"/></svg>
|
||||
<span>Verknüpft mit: ${this.escapeHtml(proposal.task_title)}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
<div class="proposal-meta">
|
||||
<div class="proposal-author">
|
||||
<span class="proposal-author-avatar" style="background-color: ${proposal.created_by_color || '#888'}">
|
||||
${initial}
|
||||
</span>
|
||||
<span>${this.escapeHtml(proposal.created_by_name)}</span>
|
||||
<span class="proposal-date">${dateStr}</span>
|
||||
</div>
|
||||
<div class="proposal-actions">
|
||||
${canApprove && !isArchived ? `
|
||||
<label class="proposal-approve ${proposal.approved ? 'approved' : ''}" title="Genehmigen">
|
||||
<input type="checkbox" data-action="approve" ${proposal.approved ? 'checked' : ''}>
|
||||
<span>Genehmigt</span>
|
||||
</label>
|
||||
` : ''}
|
||||
${canApprove ? `
|
||||
<button class="proposal-archive-btn" data-action="archive" title="${isArchived ? 'Wiederherstellen' : 'Archivieren'}">
|
||||
${isArchived ? `
|
||||
<svg viewBox="0 0 24 24"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" stroke="currentColor" stroke-width="2" fill="none"/><path d="M3 3v5h5" stroke="currentColor" stroke-width="2" fill="none"/></svg>
|
||||
` : `
|
||||
<svg viewBox="0 0 24 24"><path d="M21 8v13H3V8M1 3h22v5H1zM10 12h4" stroke="currentColor" stroke-width="2" fill="none"/></svg>
|
||||
`}
|
||||
</button>
|
||||
` : ''}
|
||||
${(isOwn || canApprove) && !isArchived ? `
|
||||
<button class="proposal-delete-btn" data-action="delete" title="Löschen">
|
||||
<svg viewBox="0 0 24 24"><path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" stroke="currentColor" stroke-width="2" fill="none"/></svg>
|
||||
</button>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
${proposal.approved && proposal.approved_by_name ? `
|
||||
<div class="proposal-approved-by">
|
||||
Genehmigt von ${this.escapeHtml(proposal.approved_by_name)} am ${this.formatDate(proposal.approved_at)}
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
bindProposalEvents() {
|
||||
this.proposalsList?.querySelectorAll('.proposal-card').forEach(card => {
|
||||
const proposalId = parseInt(card.dataset.proposalId);
|
||||
const proposal = this.proposals.find(p => p.id === proposalId);
|
||||
|
||||
// Approve checkbox
|
||||
const approveCheckbox = card.querySelector('[data-action="approve"]');
|
||||
approveCheckbox?.addEventListener('change', (e) => this.handleApprove(proposalId, e.target.checked));
|
||||
|
||||
// Archive button
|
||||
const archiveBtn = card.querySelector('[data-action="archive"]');
|
||||
archiveBtn?.addEventListener('click', () => this.handleArchive(proposalId, proposal?.archived !== 1));
|
||||
|
||||
// Delete button
|
||||
const deleteBtn = card.querySelector('[data-action="delete"]');
|
||||
deleteBtn?.addEventListener('click', () => this.handleDelete(proposalId));
|
||||
});
|
||||
}
|
||||
|
||||
async handleApprove(proposalId, approved) {
|
||||
try {
|
||||
await api.approveProposal(proposalId, approved);
|
||||
await this.loadProposals();
|
||||
this.showToast(approved ? 'Genehmigung erteilt' : 'Genehmigung zurückgezogen', 'success');
|
||||
// Board aktualisieren, damit die Genehmigung auf der Task-Karte aktualisiert wird
|
||||
window.dispatchEvent(new CustomEvent('app:refresh'));
|
||||
} catch (error) {
|
||||
this.showToast(error.message || 'Fehler beim Genehmigen', 'error');
|
||||
// Revert checkbox
|
||||
await this.loadProposals();
|
||||
}
|
||||
}
|
||||
|
||||
async handleArchive(proposalId, archive) {
|
||||
try {
|
||||
await api.archiveProposal(proposalId, archive);
|
||||
await this.loadProposals();
|
||||
this.showToast(archive ? 'Genehmigung archiviert' : 'Genehmigung wiederhergestellt', 'success');
|
||||
// Board aktualisieren
|
||||
window.dispatchEvent(new CustomEvent('app:refresh'));
|
||||
} catch (error) {
|
||||
this.showToast(error.message || 'Fehler beim Archivieren', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async handleDelete(proposalId) {
|
||||
const proposal = this.proposals.find(p => p.id === proposalId);
|
||||
if (!proposal) return;
|
||||
|
||||
const confirmDelete = confirm(`Genehmigung "${proposal.title}" wirklich löschen?`);
|
||||
if (!confirmDelete) return;
|
||||
|
||||
try {
|
||||
await api.deleteProposal(proposalId);
|
||||
this.showToast('Genehmigung gelöscht', 'success');
|
||||
await this.loadProposals();
|
||||
// Board aktualisieren
|
||||
window.dispatchEvent(new CustomEvent('app:refresh'));
|
||||
} catch (error) {
|
||||
this.showToast(error.message || 'Fehler beim Löschen', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async openNewProposalModal() {
|
||||
console.log('[Proposals] Opening new proposal modal');
|
||||
this.proposalForm?.reset();
|
||||
await this.loadTasks();
|
||||
this.openModal();
|
||||
this.proposalTitle?.focus();
|
||||
}
|
||||
|
||||
async handleProposalSubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const title = this.proposalTitle.value.trim();
|
||||
const description = this.proposalDescription.value.trim();
|
||||
const taskId = this.proposalTask?.value ? parseInt(this.proposalTask.value) : null;
|
||||
const projectId = this.getCurrentProjectId();
|
||||
|
||||
if (!title) {
|
||||
this.showToast('Bitte einen Titel eingeben', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!projectId) {
|
||||
this.showToast('Kein Projekt ausgewählt', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.createProposal({ title, description, taskId, projectId });
|
||||
this.showToast('Genehmigung erstellt', 'success');
|
||||
this.closeModal();
|
||||
await this.loadProposals();
|
||||
// Board aktualisieren, damit die Genehmigung auf der Task-Karte erscheint
|
||||
window.dispatchEvent(new CustomEvent('app:refresh'));
|
||||
} catch (error) {
|
||||
this.showToast(error.message || 'Fehler beim Erstellen', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
openModal() {
|
||||
if (this.proposalModal) {
|
||||
this.proposalModal.classList.remove('hidden');
|
||||
this.proposalModal.classList.add('visible');
|
||||
}
|
||||
const overlay = $('#modal-overlay');
|
||||
if (overlay) {
|
||||
overlay.classList.remove('hidden');
|
||||
overlay.classList.add('visible');
|
||||
}
|
||||
store.openModal('proposal-modal');
|
||||
}
|
||||
|
||||
closeModal() {
|
||||
if (this.proposalModal) {
|
||||
this.proposalModal.classList.remove('visible');
|
||||
this.proposalModal.classList.add('hidden');
|
||||
}
|
||||
// Only hide overlay if no other modals are open
|
||||
const openModals = store.get('openModals').filter(id => id !== 'proposal-modal');
|
||||
if (openModals.length === 0) {
|
||||
const overlay = $('#modal-overlay');
|
||||
if (overlay) {
|
||||
overlay.classList.remove('visible');
|
||||
overlay.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
store.closeModal('proposal-modal');
|
||||
}
|
||||
|
||||
formatDate(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
showToast(message, type = 'info') {
|
||||
window.dispatchEvent(new CustomEvent('toast:show', {
|
||||
detail: { message, type }
|
||||
}));
|
||||
}
|
||||
|
||||
escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
show() {
|
||||
this.proposalsView?.classList.remove('hidden');
|
||||
this.proposalsView?.classList.add('active');
|
||||
}
|
||||
|
||||
hide() {
|
||||
this.proposalsView?.classList.add('hidden');
|
||||
this.proposalsView?.classList.remove('active');
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrollt zu einer Genehmigung und hebt sie hervor
|
||||
*/
|
||||
scrollToAndHighlight(proposalId) {
|
||||
const card = this.proposalsList?.querySelector(`[data-proposal-id="${proposalId}"]`);
|
||||
if (!card) {
|
||||
console.warn('[Proposals] Proposal card not found:', proposalId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Scroll to the card
|
||||
card.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
|
||||
// Add highlight class for animation
|
||||
card.classList.add('highlight-pulse');
|
||||
|
||||
// Remove highlight class after animation
|
||||
setTimeout(() => {
|
||||
card.classList.remove('highlight-pulse');
|
||||
}, 2500);
|
||||
}
|
||||
}
|
||||
|
||||
// Create singleton instance
|
||||
const proposalsManager = new ProposalsManager();
|
||||
|
||||
export { proposalsManager };
|
||||
export default proposalsManager;
|
||||
141
frontend/js/shortcuts.js
Normale Datei
141
frontend/js/shortcuts.js
Normale Datei
@ -0,0 +1,141 @@
|
||||
/**
|
||||
* TASKMATE - Keyboard Shortcuts Module
|
||||
* =====================================
|
||||
* Global keyboard shortcuts (only non-browser-conflicting)
|
||||
*/
|
||||
|
||||
import store from './store.js';
|
||||
import { $ } from './utils.js';
|
||||
|
||||
class ShortcutsManager {
|
||||
constructor() {
|
||||
this.shortcuts = new Map();
|
||||
this.isEnabled = true;
|
||||
|
||||
this.registerDefaultShortcuts();
|
||||
this.bindEvents();
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
document.addEventListener('keydown', (e) => this.handleKeydown(e));
|
||||
}
|
||||
|
||||
handleKeydown(e) {
|
||||
// Don't handle shortcuts when typing in inputs
|
||||
if (this.isInputFocused()) return;
|
||||
|
||||
// Don't handle when modals are open (except escape)
|
||||
if (store.get('openModals').length > 0 && e.key !== 'Escape') return;
|
||||
|
||||
// Don't handle when disabled
|
||||
if (!this.isEnabled) return;
|
||||
|
||||
const combo = this.getKeyCombo(e);
|
||||
const handler = this.shortcuts.get(combo);
|
||||
|
||||
if (handler) {
|
||||
e.preventDefault();
|
||||
handler(e);
|
||||
}
|
||||
}
|
||||
|
||||
getKeyCombo(e) {
|
||||
const parts = [];
|
||||
|
||||
if (e.ctrlKey || e.metaKey) parts.push('Ctrl');
|
||||
if (e.altKey) parts.push('Alt');
|
||||
if (e.shiftKey) parts.push('Shift');
|
||||
|
||||
// Get the key
|
||||
let key = e.key;
|
||||
if (key === ' ') key = 'Space';
|
||||
if (key.length === 1) key = key.toUpperCase();
|
||||
|
||||
parts.push(key);
|
||||
|
||||
return parts.join('+');
|
||||
}
|
||||
|
||||
isInputFocused() {
|
||||
const activeElement = document.activeElement;
|
||||
const tagName = activeElement?.tagName.toLowerCase();
|
||||
const isContentEditable = activeElement?.contentEditable === 'true';
|
||||
|
||||
return ['input', 'textarea', 'select'].includes(tagName) || isContentEditable;
|
||||
}
|
||||
|
||||
// =====================
|
||||
// SHORTCUT REGISTRATION
|
||||
// =====================
|
||||
|
||||
register(combo, handler, description = '') {
|
||||
this.shortcuts.set(combo, handler);
|
||||
// Store description for help display
|
||||
handler.description = description;
|
||||
}
|
||||
|
||||
unregister(combo) {
|
||||
this.shortcuts.delete(combo);
|
||||
}
|
||||
|
||||
enable() {
|
||||
this.isEnabled = true;
|
||||
}
|
||||
|
||||
disable() {
|
||||
this.isEnabled = false;
|
||||
}
|
||||
|
||||
// =====================
|
||||
// DEFAULT SHORTCUTS
|
||||
// =====================
|
||||
|
||||
registerDefaultShortcuts() {
|
||||
// Only Escape - close modals (doesn't conflict with browser)
|
||||
this.register('Escape', () => this.handleEscape(), 'Schließen / Abbrechen');
|
||||
}
|
||||
|
||||
// =====================
|
||||
// SHORTCUT HANDLERS
|
||||
// =====================
|
||||
|
||||
handleEscape() {
|
||||
const openModals = store.get('openModals');
|
||||
|
||||
if (openModals.length > 0) {
|
||||
// Close the topmost modal
|
||||
const topModal = openModals[openModals.length - 1];
|
||||
window.dispatchEvent(new CustomEvent('modal:close', {
|
||||
detail: { modalId: topModal }
|
||||
}));
|
||||
} else {
|
||||
// Clear selection
|
||||
store.clearSelection();
|
||||
}
|
||||
}
|
||||
|
||||
showHelp() {
|
||||
// No longer needed - shortcuts removed
|
||||
}
|
||||
|
||||
// =====================
|
||||
// HELP DISPLAY
|
||||
// =====================
|
||||
|
||||
getShortcutsList() {
|
||||
// Minimal shortcuts that don't conflict with browser
|
||||
return {
|
||||
actions: {
|
||||
title: 'Aktionen',
|
||||
shortcuts: [
|
||||
{ combo: 'Escape', description: 'Dialog schließen / Auswahl aufheben' }
|
||||
]
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Create and export singleton
|
||||
const shortcutsManager = new ShortcutsManager();
|
||||
|
||||
export default shortcutsManager;
|
||||
584
frontend/js/store.js
Normale Datei
584
frontend/js/store.js
Normale Datei
@ -0,0 +1,584 @@
|
||||
/**
|
||||
* TASKMATE - State Store
|
||||
* ======================
|
||||
* Centralized state management
|
||||
*/
|
||||
|
||||
import { deepClone, deepMerge } from './utils.js';
|
||||
|
||||
class Store {
|
||||
constructor() {
|
||||
this.state = {
|
||||
// App state
|
||||
currentView: 'board',
|
||||
isOnline: navigator.onLine,
|
||||
isLoading: false,
|
||||
syncStatus: 'synced', // 'synced', 'syncing', 'offline', 'error'
|
||||
|
||||
// User state
|
||||
currentUser: null,
|
||||
users: [],
|
||||
|
||||
// Project state
|
||||
projects: [],
|
||||
currentProjectId: null,
|
||||
|
||||
// Board state
|
||||
columns: [],
|
||||
tasks: [],
|
||||
labels: [],
|
||||
|
||||
// Filters
|
||||
filters: {
|
||||
search: '',
|
||||
priority: 'all',
|
||||
assignee: 'all',
|
||||
label: 'all',
|
||||
dueDate: 'all',
|
||||
archived: false
|
||||
},
|
||||
|
||||
// Server search result IDs (bypass client filter for these)
|
||||
searchResultIds: [],
|
||||
|
||||
// Selection
|
||||
selectedTaskIds: [],
|
||||
|
||||
// Modal state
|
||||
openModals: [],
|
||||
editingTask: null,
|
||||
|
||||
// UI state
|
||||
dragState: null,
|
||||
contextMenu: null,
|
||||
|
||||
// Undo stack
|
||||
undoStack: [],
|
||||
redoStack: [],
|
||||
maxUndoHistory: 50
|
||||
};
|
||||
|
||||
this.subscribers = new Map();
|
||||
this.middlewares = [];
|
||||
}
|
||||
|
||||
// Get current state
|
||||
getState() {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
// Get specific state path
|
||||
get(path) {
|
||||
return path.split('.').reduce((obj, key) => obj?.[key], this.state);
|
||||
}
|
||||
|
||||
// Update state
|
||||
setState(updates, actionType = 'SET_STATE') {
|
||||
const prevState = deepClone(this.state);
|
||||
|
||||
// Apply middlewares
|
||||
let processedUpdates = updates;
|
||||
for (const middleware of this.middlewares) {
|
||||
processedUpdates = middleware(prevState, processedUpdates, actionType);
|
||||
}
|
||||
|
||||
// Merge updates
|
||||
this.state = deepMerge(this.state, processedUpdates);
|
||||
|
||||
// Notify subscribers
|
||||
this.notifySubscribers(prevState, this.state, actionType);
|
||||
|
||||
return this.state;
|
||||
}
|
||||
|
||||
// Set specific state path
|
||||
set(path, value, actionType) {
|
||||
const keys = path.split('.');
|
||||
const updates = keys.reduceRight((acc, key) => ({ [key]: acc }), value);
|
||||
return this.setState(updates, actionType || `SET_${path.toUpperCase()}`);
|
||||
}
|
||||
|
||||
// Subscribe to state changes
|
||||
subscribe(selector, callback, immediate = false) {
|
||||
const id = Symbol();
|
||||
|
||||
this.subscribers.set(id, { selector, callback });
|
||||
|
||||
if (immediate) {
|
||||
const currentValue = typeof selector === 'function'
|
||||
? selector(this.state)
|
||||
: this.get(selector);
|
||||
callback(currentValue, currentValue);
|
||||
}
|
||||
|
||||
// Return unsubscribe function
|
||||
return () => this.subscribers.delete(id);
|
||||
}
|
||||
|
||||
// Notify subscribers of changes
|
||||
notifySubscribers(prevState, newState, actionType) {
|
||||
this.subscribers.forEach(({ selector, callback }) => {
|
||||
const prevValue = typeof selector === 'function'
|
||||
? selector(prevState)
|
||||
: selector.split('.').reduce((obj, key) => obj?.[key], prevState);
|
||||
|
||||
const newValue = typeof selector === 'function'
|
||||
? selector(newState)
|
||||
: selector.split('.').reduce((obj, key) => obj?.[key], newState);
|
||||
|
||||
// Only call if value changed
|
||||
if (JSON.stringify(prevValue) !== JSON.stringify(newValue)) {
|
||||
callback(newValue, prevValue, actionType);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Add middleware
|
||||
use(middleware) {
|
||||
this.middlewares.push(middleware);
|
||||
}
|
||||
|
||||
// Reset state
|
||||
reset() {
|
||||
this.state = {
|
||||
currentView: 'board',
|
||||
isOnline: navigator.onLine,
|
||||
isLoading: false,
|
||||
syncStatus: 'synced',
|
||||
currentUser: null,
|
||||
users: [],
|
||||
projects: [],
|
||||
currentProjectId: null,
|
||||
columns: [],
|
||||
tasks: [],
|
||||
labels: [],
|
||||
filters: {
|
||||
search: '',
|
||||
priority: 'all',
|
||||
assignee: 'all',
|
||||
label: 'all',
|
||||
dueDate: 'all',
|
||||
archived: false
|
||||
},
|
||||
searchResultIds: [],
|
||||
selectedTaskIds: [],
|
||||
openModals: [],
|
||||
editingTask: null,
|
||||
dragState: null,
|
||||
contextMenu: null,
|
||||
undoStack: [],
|
||||
redoStack: [],
|
||||
maxUndoHistory: 50
|
||||
};
|
||||
}
|
||||
|
||||
// =====================
|
||||
// PROJECT ACTIONS
|
||||
// =====================
|
||||
|
||||
setProjects(projects) {
|
||||
this.setState({ projects }, 'SET_PROJECTS');
|
||||
}
|
||||
|
||||
addProject(project) {
|
||||
this.setState({
|
||||
projects: [...this.state.projects, project]
|
||||
}, 'ADD_PROJECT');
|
||||
}
|
||||
|
||||
updateProject(projectId, updates) {
|
||||
this.setState({
|
||||
projects: this.state.projects.map(p =>
|
||||
p.id === projectId ? { ...p, ...updates } : p
|
||||
)
|
||||
}, 'UPDATE_PROJECT');
|
||||
}
|
||||
|
||||
removeProject(projectId) {
|
||||
this.setState({
|
||||
projects: this.state.projects.filter(p => p.id !== projectId)
|
||||
}, 'REMOVE_PROJECT');
|
||||
}
|
||||
|
||||
setCurrentProject(projectId) {
|
||||
this.setState({ currentProjectId: projectId }, 'SET_CURRENT_PROJECT');
|
||||
localStorage.setItem('current_project_id', projectId);
|
||||
}
|
||||
|
||||
getCurrentProject() {
|
||||
return this.state.projects.find(p => p.id === this.state.currentProjectId);
|
||||
}
|
||||
|
||||
// =====================
|
||||
// COLUMN ACTIONS
|
||||
// =====================
|
||||
|
||||
setColumns(columns) {
|
||||
this.setState({ columns }, 'SET_COLUMNS');
|
||||
}
|
||||
|
||||
addColumn(column) {
|
||||
this.setState({
|
||||
columns: [...this.state.columns, column]
|
||||
}, 'ADD_COLUMN');
|
||||
}
|
||||
|
||||
updateColumn(columnId, updates) {
|
||||
this.setState({
|
||||
columns: this.state.columns.map(c =>
|
||||
c.id === columnId ? { ...c, ...updates } : c
|
||||
)
|
||||
}, 'UPDATE_COLUMN');
|
||||
}
|
||||
|
||||
removeColumn(columnId) {
|
||||
this.setState({
|
||||
columns: this.state.columns.filter(c => c.id !== columnId),
|
||||
tasks: this.state.tasks.filter(t => t.columnId !== columnId)
|
||||
}, 'REMOVE_COLUMN');
|
||||
}
|
||||
|
||||
reorderColumns(columnIds) {
|
||||
const columnsMap = new Map(this.state.columns.map(c => [c.id, c]));
|
||||
const reordered = columnIds.map((id, index) => ({
|
||||
...columnsMap.get(id),
|
||||
position: index
|
||||
}));
|
||||
|
||||
this.setState({ columns: reordered }, 'REORDER_COLUMNS');
|
||||
}
|
||||
|
||||
// =====================
|
||||
// TASK ACTIONS
|
||||
// =====================
|
||||
|
||||
setTasks(tasks) {
|
||||
this.setState({ tasks }, 'SET_TASKS');
|
||||
}
|
||||
|
||||
addTask(task) {
|
||||
this.setState({
|
||||
tasks: [...this.state.tasks, task]
|
||||
}, 'ADD_TASK');
|
||||
}
|
||||
|
||||
updateTask(taskId, updates) {
|
||||
this.setState({
|
||||
tasks: this.state.tasks.map(t =>
|
||||
t.id === taskId ? { ...t, ...updates } : t
|
||||
)
|
||||
}, 'UPDATE_TASK');
|
||||
}
|
||||
|
||||
removeTask(taskId) {
|
||||
this.setState({
|
||||
tasks: this.state.tasks.filter(t => t.id !== taskId),
|
||||
selectedTaskIds: this.state.selectedTaskIds.filter(id => id !== taskId)
|
||||
}, 'REMOVE_TASK');
|
||||
}
|
||||
|
||||
moveTask(taskId, columnId, position) {
|
||||
const tasks = [...this.state.tasks];
|
||||
const taskIndex = tasks.findIndex(t => t.id === taskId);
|
||||
|
||||
if (taskIndex === -1) return;
|
||||
|
||||
const task = { ...tasks[taskIndex], columnId: columnId, position };
|
||||
tasks.splice(taskIndex, 1);
|
||||
|
||||
// Find insert position
|
||||
const columnTasks = tasks.filter(t => t.columnId === columnId);
|
||||
const insertIndex = tasks.findIndex(t => t.columnId === columnId && t.position >= position);
|
||||
|
||||
if (insertIndex === -1) {
|
||||
tasks.push(task);
|
||||
} else {
|
||||
tasks.splice(insertIndex, 0, task);
|
||||
}
|
||||
|
||||
// Recalculate positions
|
||||
let pos = 0;
|
||||
tasks.forEach(t => {
|
||||
if (t.columnId === columnId) {
|
||||
t.position = pos++;
|
||||
}
|
||||
});
|
||||
|
||||
this.setState({ tasks }, 'MOVE_TASK');
|
||||
}
|
||||
|
||||
getTaskById(taskId) {
|
||||
return this.state.tasks.find(t => t.id === taskId);
|
||||
}
|
||||
|
||||
getTasksByColumn(columnId) {
|
||||
// Priority order: high (0) > medium (1) > low (2)
|
||||
const priorityOrder = { high: 0, medium: 1, low: 2 };
|
||||
|
||||
return this.state.tasks
|
||||
.filter(t => t.columnId === columnId && !t.archived)
|
||||
.sort((a, b) => {
|
||||
// 1. First by position (manual drag&drop sorting)
|
||||
const posA = a.position ?? 999999;
|
||||
const posB = b.position ?? 999999;
|
||||
if (posA !== posB) return posA - posB;
|
||||
|
||||
// 2. Then by priority (high > medium > low)
|
||||
const priA = priorityOrder[a.priority] ?? 1;
|
||||
const priB = priorityOrder[b.priority] ?? 1;
|
||||
if (priA !== priB) return priA - priB;
|
||||
|
||||
// 3. Then by creation date (older first)
|
||||
const dateA = new Date(a.createdAt || 0).getTime();
|
||||
const dateB = new Date(b.createdAt || 0).getTime();
|
||||
return dateA - dateB;
|
||||
});
|
||||
}
|
||||
|
||||
// =====================
|
||||
// LABEL ACTIONS
|
||||
// =====================
|
||||
|
||||
setLabels(labels) {
|
||||
this.setState({ labels }, 'SET_LABELS');
|
||||
}
|
||||
|
||||
addLabel(label) {
|
||||
this.setState({
|
||||
labels: [...this.state.labels, label]
|
||||
}, 'ADD_LABEL');
|
||||
}
|
||||
|
||||
updateLabel(labelId, updates) {
|
||||
this.setState({
|
||||
labels: this.state.labels.map(l =>
|
||||
l.id === labelId ? { ...l, ...updates } : l
|
||||
)
|
||||
}, 'UPDATE_LABEL');
|
||||
}
|
||||
|
||||
removeLabel(labelId) {
|
||||
this.setState({
|
||||
labels: this.state.labels.filter(l => l.id !== labelId)
|
||||
}, 'REMOVE_LABEL');
|
||||
}
|
||||
|
||||
// =====================
|
||||
// FILTER ACTIONS
|
||||
// =====================
|
||||
|
||||
setFilter(key, value) {
|
||||
this.setState({
|
||||
filters: { ...this.state.filters, [key]: value }
|
||||
}, 'SET_FILTER');
|
||||
}
|
||||
|
||||
setFilters(filters) {
|
||||
this.setState({
|
||||
filters: { ...this.state.filters, ...filters }
|
||||
}, 'SET_FILTERS');
|
||||
}
|
||||
|
||||
resetFilters() {
|
||||
this.setState({
|
||||
filters: {
|
||||
search: '',
|
||||
priority: 'all',
|
||||
assignee: 'all',
|
||||
label: 'all',
|
||||
dueDate: 'all',
|
||||
archived: false
|
||||
}
|
||||
}, 'RESET_FILTERS');
|
||||
}
|
||||
|
||||
// =====================
|
||||
// SELECTION ACTIONS
|
||||
// =====================
|
||||
|
||||
selectTask(taskId, multi = false) {
|
||||
if (multi) {
|
||||
const selected = this.state.selectedTaskIds.includes(taskId)
|
||||
? this.state.selectedTaskIds.filter(id => id !== taskId)
|
||||
: [...this.state.selectedTaskIds, taskId];
|
||||
|
||||
this.setState({ selectedTaskIds: selected }, 'SELECT_TASK');
|
||||
} else {
|
||||
this.setState({ selectedTaskIds: [taskId] }, 'SELECT_TASK');
|
||||
}
|
||||
}
|
||||
|
||||
deselectTask(taskId) {
|
||||
this.setState({
|
||||
selectedTaskIds: this.state.selectedTaskIds.filter(id => id !== taskId)
|
||||
}, 'DESELECT_TASK');
|
||||
}
|
||||
|
||||
clearSelection() {
|
||||
this.setState({ selectedTaskIds: [] }, 'CLEAR_SELECTION');
|
||||
}
|
||||
|
||||
selectAllInColumn(columnId) {
|
||||
const taskIds = this.getTasksByColumn(columnId).map(t => t.id);
|
||||
this.setState({ selectedTaskIds: taskIds }, 'SELECT_ALL_IN_COLUMN');
|
||||
}
|
||||
|
||||
// =====================
|
||||
// UI STATE ACTIONS
|
||||
// =====================
|
||||
|
||||
setCurrentView(view) {
|
||||
this.setState({ currentView: view }, 'SET_VIEW');
|
||||
}
|
||||
|
||||
setLoading(isLoading) {
|
||||
this.setState({ isLoading }, 'SET_LOADING');
|
||||
}
|
||||
|
||||
setOnline(isOnline) {
|
||||
this.setState({
|
||||
isOnline,
|
||||
syncStatus: isOnline ? 'synced' : 'offline'
|
||||
}, 'SET_ONLINE');
|
||||
}
|
||||
|
||||
setSyncStatus(status) {
|
||||
this.setState({ syncStatus: status }, 'SET_SYNC_STATUS');
|
||||
}
|
||||
|
||||
setDragState(dragState) {
|
||||
this.setState({ dragState }, 'SET_DRAG_STATE');
|
||||
}
|
||||
|
||||
setEditingTask(task) {
|
||||
this.setState({ editingTask: task }, 'SET_EDITING_TASK');
|
||||
}
|
||||
|
||||
// =====================
|
||||
// MODAL ACTIONS
|
||||
// =====================
|
||||
|
||||
openModal(modalId) {
|
||||
if (!this.state.openModals.includes(modalId)) {
|
||||
this.setState({
|
||||
openModals: [...this.state.openModals, modalId]
|
||||
}, 'OPEN_MODAL');
|
||||
}
|
||||
}
|
||||
|
||||
closeModal(modalId) {
|
||||
this.setState({
|
||||
openModals: this.state.openModals.filter(id => id !== modalId)
|
||||
}, 'CLOSE_MODAL');
|
||||
}
|
||||
|
||||
closeAllModals() {
|
||||
this.setState({ openModals: [] }, 'CLOSE_ALL_MODALS');
|
||||
}
|
||||
|
||||
isModalOpen(modalId) {
|
||||
return this.state.openModals.includes(modalId);
|
||||
}
|
||||
|
||||
// =====================
|
||||
// UNDO/REDO ACTIONS
|
||||
// =====================
|
||||
|
||||
pushUndo(action) {
|
||||
const undoStack = [...this.state.undoStack, action];
|
||||
|
||||
// Limit stack size
|
||||
if (undoStack.length > this.state.maxUndoHistory) {
|
||||
undoStack.shift();
|
||||
}
|
||||
|
||||
this.setState({
|
||||
undoStack,
|
||||
redoStack: [] // Clear redo stack on new action
|
||||
}, 'PUSH_UNDO');
|
||||
}
|
||||
|
||||
popUndo() {
|
||||
if (this.state.undoStack.length === 0) return null;
|
||||
|
||||
const undoStack = [...this.state.undoStack];
|
||||
const action = undoStack.pop();
|
||||
|
||||
this.setState({
|
||||
undoStack,
|
||||
redoStack: [...this.state.redoStack, action]
|
||||
}, 'POP_UNDO');
|
||||
|
||||
return action;
|
||||
}
|
||||
|
||||
popRedo() {
|
||||
if (this.state.redoStack.length === 0) return null;
|
||||
|
||||
const redoStack = [...this.state.redoStack];
|
||||
const action = redoStack.pop();
|
||||
|
||||
this.setState({
|
||||
redoStack,
|
||||
undoStack: [...this.state.undoStack, action]
|
||||
}, 'POP_REDO');
|
||||
|
||||
return action;
|
||||
}
|
||||
|
||||
canUndo() {
|
||||
return this.state.undoStack.length > 0;
|
||||
}
|
||||
|
||||
canRedo() {
|
||||
return this.state.redoStack.length > 0;
|
||||
}
|
||||
|
||||
// =====================
|
||||
// USER ACTIONS
|
||||
// =====================
|
||||
|
||||
setCurrentUser(user) {
|
||||
this.setState({ currentUser: user }, 'SET_CURRENT_USER');
|
||||
}
|
||||
|
||||
setUsers(users) {
|
||||
this.setState({ users }, 'SET_USERS');
|
||||
}
|
||||
|
||||
getUserById(userId) {
|
||||
return this.state.users.find(u => u.id === userId);
|
||||
}
|
||||
}
|
||||
|
||||
// Create singleton instance
|
||||
const store = new Store();
|
||||
|
||||
// Debug middleware (only in development)
|
||||
if (window.location.hostname === 'localhost') {
|
||||
store.use((prevState, updates, actionType) => {
|
||||
console.log(`[Store] ${actionType}:`, updates);
|
||||
return updates;
|
||||
});
|
||||
}
|
||||
|
||||
// Persistence middleware for filters (EXCLUDING search to prevent "hanging" search)
|
||||
store.subscribe('filters', (filters) => {
|
||||
// Save filters WITHOUT search - search should not persist across sessions
|
||||
const { search, ...filtersToSave } = filters;
|
||||
localStorage.setItem('task_filters', JSON.stringify(filtersToSave));
|
||||
});
|
||||
|
||||
// Load persisted filters
|
||||
const savedFilters = localStorage.getItem('task_filters');
|
||||
if (savedFilters) {
|
||||
try {
|
||||
const parsed = JSON.parse(savedFilters);
|
||||
// Ensure search is always empty on load
|
||||
store.setFilters({ ...parsed, search: '' });
|
||||
} catch (e) {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
|
||||
export default store;
|
||||
577
frontend/js/sync.js
Normale Datei
577
frontend/js/sync.js
Normale Datei
@ -0,0 +1,577 @@
|
||||
/**
|
||||
* TASKMATE - Real-time Sync Module
|
||||
* =================================
|
||||
* WebSocket synchronization with Socket.io
|
||||
*/
|
||||
|
||||
import store from './store.js';
|
||||
import api from './api.js';
|
||||
|
||||
class SyncManager {
|
||||
constructor() {
|
||||
this.socket = null;
|
||||
this.isConnected = false;
|
||||
this.reconnectAttempts = 0;
|
||||
this.maxReconnectAttempts = 10;
|
||||
this.reconnectDelay = 1000;
|
||||
this.pendingOperations = [];
|
||||
this.operationQueue = [];
|
||||
this.isProcessingQueue = false;
|
||||
}
|
||||
|
||||
// Initialize Socket.io connection
|
||||
async connect() {
|
||||
if (this.socket?.connected) {
|
||||
return;
|
||||
}
|
||||
|
||||
const token = api.getToken();
|
||||
if (!token) {
|
||||
console.log('[Sync] No auth token, skipping connection');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Socket.io is loaded globally via script tag
|
||||
if (typeof io === 'undefined') {
|
||||
throw new Error('Socket.io not loaded');
|
||||
}
|
||||
|
||||
this.socket = io({
|
||||
auth: { token },
|
||||
reconnection: true,
|
||||
reconnectionAttempts: this.maxReconnectAttempts,
|
||||
reconnectionDelay: this.reconnectDelay,
|
||||
reconnectionDelayMax: 10000,
|
||||
timeout: 20000
|
||||
});
|
||||
|
||||
this.setupEventListeners();
|
||||
} catch (error) {
|
||||
console.error('[Sync] Failed to connect:', error);
|
||||
store.setSyncStatus('error');
|
||||
}
|
||||
}
|
||||
|
||||
// Setup socket event listeners
|
||||
setupEventListeners() {
|
||||
if (!this.socket) return;
|
||||
|
||||
// Connection events
|
||||
this.socket.on('connect', () => {
|
||||
console.log('[Sync] Connected');
|
||||
this.isConnected = true;
|
||||
this.reconnectAttempts = 0;
|
||||
store.setSyncStatus('synced');
|
||||
|
||||
// Join current project room
|
||||
const projectId = store.get('currentProjectId');
|
||||
if (projectId) {
|
||||
this.joinProject(projectId);
|
||||
}
|
||||
|
||||
// Process pending operations
|
||||
this.processPendingOperations();
|
||||
});
|
||||
|
||||
this.socket.on('disconnect', (reason) => {
|
||||
console.log('[Sync] Disconnected:', reason);
|
||||
this.isConnected = false;
|
||||
|
||||
if (reason === 'io server disconnect') {
|
||||
// Server initiated disconnect, try reconnecting
|
||||
this.socket.connect();
|
||||
}
|
||||
|
||||
store.setSyncStatus('offline');
|
||||
});
|
||||
|
||||
this.socket.on('connect_error', (error) => {
|
||||
console.error('[Sync] Connection error:', error);
|
||||
this.reconnectAttempts++;
|
||||
|
||||
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
||||
store.setSyncStatus('error');
|
||||
} else {
|
||||
store.setSyncStatus('offline');
|
||||
}
|
||||
});
|
||||
|
||||
// Auth error
|
||||
this.socket.on('error', (error) => {
|
||||
console.error('[Sync] Socket error:', error);
|
||||
|
||||
if (error.type === 'auth') {
|
||||
// Auth failed, logout
|
||||
window.dispatchEvent(new CustomEvent('auth:logout'));
|
||||
}
|
||||
});
|
||||
|
||||
// Data sync events
|
||||
this.socket.on('project:updated', (data) => {
|
||||
this.handleProjectUpdated(data);
|
||||
});
|
||||
|
||||
this.socket.on('column:created', (data) => {
|
||||
this.handleColumnCreated(data);
|
||||
});
|
||||
|
||||
this.socket.on('column:updated', (data) => {
|
||||
this.handleColumnUpdated(data);
|
||||
});
|
||||
|
||||
this.socket.on('column:deleted', (data) => {
|
||||
this.handleColumnDeleted(data);
|
||||
});
|
||||
|
||||
this.socket.on('column:reordered', (data) => {
|
||||
this.handleColumnsReordered(data);
|
||||
});
|
||||
|
||||
this.socket.on('task:created', (data) => {
|
||||
this.handleTaskCreated(data);
|
||||
});
|
||||
|
||||
this.socket.on('task:updated', (data) => {
|
||||
this.handleTaskUpdated(data);
|
||||
});
|
||||
|
||||
this.socket.on('task:deleted', (data) => {
|
||||
this.handleTaskDeleted(data);
|
||||
});
|
||||
|
||||
this.socket.on('task:moved', (data) => {
|
||||
this.handleTaskMoved(data);
|
||||
});
|
||||
|
||||
this.socket.on('label:created', (data) => {
|
||||
this.handleLabelCreated(data);
|
||||
});
|
||||
|
||||
this.socket.on('label:updated', (data) => {
|
||||
this.handleLabelUpdated(data);
|
||||
});
|
||||
|
||||
this.socket.on('label:deleted', (data) => {
|
||||
this.handleLabelDeleted(data);
|
||||
});
|
||||
|
||||
this.socket.on('comment:created', (data) => {
|
||||
this.handleCommentCreated(data);
|
||||
});
|
||||
|
||||
this.socket.on('subtask:updated', (data) => {
|
||||
this.handleSubtaskUpdated(data);
|
||||
});
|
||||
|
||||
// User presence events
|
||||
this.socket.on('user:joined', (data) => {
|
||||
this.handleUserJoined(data);
|
||||
});
|
||||
|
||||
this.socket.on('user:left', (data) => {
|
||||
this.handleUserLeft(data);
|
||||
});
|
||||
|
||||
this.socket.on('user:typing', (data) => {
|
||||
this.handleUserTyping(data);
|
||||
});
|
||||
|
||||
// Notification events
|
||||
this.socket.on('notification:new', (data) => {
|
||||
this.handleNotificationNew(data);
|
||||
});
|
||||
|
||||
this.socket.on('notification:count', (data) => {
|
||||
this.handleNotificationCount(data);
|
||||
});
|
||||
|
||||
this.socket.on('notification:deleted', (data) => {
|
||||
this.handleNotificationDeleted(data);
|
||||
});
|
||||
}
|
||||
|
||||
// Disconnect socket
|
||||
disconnect() {
|
||||
if (this.socket) {
|
||||
this.socket.disconnect();
|
||||
this.socket = null;
|
||||
this.isConnected = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Join project room
|
||||
joinProject(projectId) {
|
||||
if (!this.socket?.connected) return;
|
||||
|
||||
this.socket.emit('project:join', { projectId });
|
||||
console.log('[Sync] Joined project:', projectId);
|
||||
}
|
||||
|
||||
// Leave project room
|
||||
leaveProject(projectId) {
|
||||
if (!this.socket?.connected) return;
|
||||
|
||||
this.socket.emit('project:leave', { projectId });
|
||||
console.log('[Sync] Left project:', projectId);
|
||||
}
|
||||
|
||||
// Emit event with queueing for offline support
|
||||
emit(event, data) {
|
||||
if (this.socket?.connected) {
|
||||
this.socket.emit(event, data);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Queue for later
|
||||
this.pendingOperations.push({ event, data, timestamp: Date.now() });
|
||||
store.setSyncStatus('offline');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Process pending operations when reconnected
|
||||
async processPendingOperations() {
|
||||
if (!this.socket?.connected || this.pendingOperations.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[Sync] Processing ${this.pendingOperations.length} pending operations`);
|
||||
store.setSyncStatus('syncing');
|
||||
|
||||
const operations = [...this.pendingOperations];
|
||||
this.pendingOperations = [];
|
||||
|
||||
for (const op of operations) {
|
||||
try {
|
||||
this.socket.emit(op.event, op.data);
|
||||
await this.delay(100); // Small delay between operations
|
||||
} catch (error) {
|
||||
console.error('[Sync] Failed to process operation:', error);
|
||||
// Re-queue failed operation
|
||||
this.pendingOperations.push(op);
|
||||
}
|
||||
}
|
||||
|
||||
store.setSyncStatus(this.pendingOperations.length > 0 ? 'offline' : 'synced');
|
||||
}
|
||||
|
||||
// Helper delay function
|
||||
delay(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
// =====================
|
||||
// EVENT HANDLERS
|
||||
// =====================
|
||||
|
||||
handleProjectUpdated(data) {
|
||||
const { project, userId } = data;
|
||||
|
||||
// Ignore own updates
|
||||
if (userId === store.get('currentUser')?.id) return;
|
||||
|
||||
store.updateProject(project.id, project);
|
||||
|
||||
this.showNotification('Projekt aktualisiert', `${data.username} hat das Projekt aktualisiert`);
|
||||
}
|
||||
|
||||
handleColumnCreated(data) {
|
||||
const { column, userId } = data;
|
||||
|
||||
if (userId === store.get('currentUser')?.id) return;
|
||||
|
||||
store.addColumn(column);
|
||||
|
||||
this.showNotification('Neue Spalte', `${data.username} hat eine Spalte erstellt`);
|
||||
}
|
||||
|
||||
handleColumnUpdated(data) {
|
||||
const { column, userId } = data;
|
||||
|
||||
if (userId === store.get('currentUser')?.id) return;
|
||||
|
||||
store.updateColumn(column.id, column);
|
||||
}
|
||||
|
||||
handleColumnDeleted(data) {
|
||||
const { columnId, userId } = data;
|
||||
|
||||
if (userId === store.get('currentUser')?.id) return;
|
||||
|
||||
store.removeColumn(columnId);
|
||||
}
|
||||
|
||||
handleColumnsReordered(data) {
|
||||
const { columnIds, userId } = data;
|
||||
|
||||
if (userId === store.get('currentUser')?.id) return;
|
||||
|
||||
store.reorderColumns(columnIds);
|
||||
}
|
||||
|
||||
handleTaskCreated(data) {
|
||||
const { task, userId, username } = data;
|
||||
|
||||
if (userId === store.get('currentUser')?.id) return;
|
||||
|
||||
store.addTask(task);
|
||||
|
||||
this.showNotification('Neue Aufgabe', `${username} hat "${task.title}" erstellt`);
|
||||
}
|
||||
|
||||
handleTaskUpdated(data) {
|
||||
const { task, userId, changes } = data;
|
||||
|
||||
if (userId === store.get('currentUser')?.id) return;
|
||||
|
||||
store.updateTask(task.id, task);
|
||||
|
||||
// Show notification for significant changes
|
||||
if (changes.includes('status') || changes.includes('assignee')) {
|
||||
this.showNotification('Aufgabe aktualisiert', `"${task.title}" wurde aktualisiert`);
|
||||
}
|
||||
}
|
||||
|
||||
handleTaskDeleted(data) {
|
||||
const { taskId, userId, taskTitle } = data;
|
||||
|
||||
if (userId === store.get('currentUser')?.id) return;
|
||||
|
||||
store.removeTask(taskId);
|
||||
|
||||
this.showNotification('Aufgabe gelöscht', `"${taskTitle}" wurde gelöscht`);
|
||||
}
|
||||
|
||||
handleTaskMoved(data) {
|
||||
const { taskId, columnId, position, userId } = data;
|
||||
|
||||
if (userId === store.get('currentUser')?.id) return;
|
||||
|
||||
store.moveTask(taskId, columnId, position);
|
||||
}
|
||||
|
||||
handleLabelCreated(data) {
|
||||
const { label, userId } = data;
|
||||
|
||||
if (userId === store.get('currentUser')?.id) return;
|
||||
|
||||
store.addLabel(label);
|
||||
}
|
||||
|
||||
handleLabelUpdated(data) {
|
||||
const { label, userId } = data;
|
||||
|
||||
if (userId === store.get('currentUser')?.id) return;
|
||||
|
||||
store.updateLabel(label.id, label);
|
||||
}
|
||||
|
||||
handleLabelDeleted(data) {
|
||||
const { labelId, userId } = data;
|
||||
|
||||
if (userId === store.get('currentUser')?.id) return;
|
||||
|
||||
store.removeLabel(labelId);
|
||||
}
|
||||
|
||||
handleCommentCreated(data) {
|
||||
const { taskId, comment, userId, username } = data;
|
||||
|
||||
if (userId === store.get('currentUser')?.id) return;
|
||||
|
||||
// Update task comment count
|
||||
const task = store.getTaskById(taskId);
|
||||
if (task) {
|
||||
store.updateTask(taskId, {
|
||||
commentCount: (task.commentCount || 0) + 1
|
||||
});
|
||||
}
|
||||
|
||||
// Check for mention
|
||||
const currentUser = store.get('currentUser');
|
||||
if (comment.content.includes(`@${currentUser?.username}`)) {
|
||||
this.showNotification(
|
||||
'Du wurdest erwähnt',
|
||||
`${username} hat dich in "${task?.title}" erwähnt`,
|
||||
'mention'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
handleSubtaskUpdated(data) {
|
||||
const { taskId, subtask, userId } = data;
|
||||
|
||||
if (userId === store.get('currentUser')?.id) return;
|
||||
|
||||
// Trigger task refresh if viewing this task
|
||||
const editingTask = store.get('editingTask');
|
||||
if (editingTask?.id === taskId) {
|
||||
window.dispatchEvent(new CustomEvent('task:refresh', {
|
||||
detail: { taskId }
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
handleUserJoined(data) {
|
||||
const { userId, username } = data;
|
||||
|
||||
if (userId === store.get('currentUser')?.id) return;
|
||||
|
||||
this.showNotification('Benutzer online', `${username} ist jetzt online`, 'info');
|
||||
}
|
||||
|
||||
handleUserLeft(data) {
|
||||
const { userId, username } = data;
|
||||
|
||||
if (userId === store.get('currentUser')?.id) return;
|
||||
|
||||
// Optional: show notification
|
||||
}
|
||||
|
||||
handleUserTyping(data) {
|
||||
const { userId, taskId, isTyping } = data;
|
||||
|
||||
if (userId === store.get('currentUser')?.id) return;
|
||||
|
||||
window.dispatchEvent(new CustomEvent('user:typing', {
|
||||
detail: { userId, taskId, isTyping }
|
||||
}));
|
||||
}
|
||||
|
||||
handleNotificationNew(data) {
|
||||
const { notification } = data;
|
||||
window.dispatchEvent(new CustomEvent('notification:new', {
|
||||
detail: { notification }
|
||||
}));
|
||||
}
|
||||
|
||||
handleNotificationCount(data) {
|
||||
const { count } = data;
|
||||
window.dispatchEvent(new CustomEvent('notification:count', {
|
||||
detail: { count }
|
||||
}));
|
||||
}
|
||||
|
||||
handleNotificationDeleted(data) {
|
||||
const { notificationId } = data;
|
||||
window.dispatchEvent(new CustomEvent('notification:deleted', {
|
||||
detail: { notificationId }
|
||||
}));
|
||||
}
|
||||
|
||||
// =====================
|
||||
// OUTGOING EVENTS
|
||||
// =====================
|
||||
|
||||
notifyTaskCreated(task) {
|
||||
this.emit('task:create', {
|
||||
task,
|
||||
projectId: store.get('currentProjectId')
|
||||
});
|
||||
}
|
||||
|
||||
notifyTaskUpdated(task, changes) {
|
||||
this.emit('task:update', {
|
||||
task,
|
||||
changes,
|
||||
projectId: store.get('currentProjectId')
|
||||
});
|
||||
}
|
||||
|
||||
notifyTaskDeleted(taskId, taskTitle) {
|
||||
this.emit('task:delete', {
|
||||
taskId,
|
||||
taskTitle,
|
||||
projectId: store.get('currentProjectId')
|
||||
});
|
||||
}
|
||||
|
||||
notifyTaskMoved(taskId, columnId, position) {
|
||||
this.emit('task:move', {
|
||||
taskId,
|
||||
columnId,
|
||||
position,
|
||||
projectId: store.get('currentProjectId')
|
||||
});
|
||||
}
|
||||
|
||||
notifyColumnCreated(column) {
|
||||
this.emit('column:create', {
|
||||
column,
|
||||
projectId: store.get('currentProjectId')
|
||||
});
|
||||
}
|
||||
|
||||
notifyColumnUpdated(column) {
|
||||
this.emit('column:update', {
|
||||
column,
|
||||
projectId: store.get('currentProjectId')
|
||||
});
|
||||
}
|
||||
|
||||
notifyColumnDeleted(columnId) {
|
||||
this.emit('column:delete', {
|
||||
columnId,
|
||||
projectId: store.get('currentProjectId')
|
||||
});
|
||||
}
|
||||
|
||||
notifyColumnsReordered(columnIds) {
|
||||
this.emit('column:reorder', {
|
||||
columnIds,
|
||||
projectId: store.get('currentProjectId')
|
||||
});
|
||||
}
|
||||
|
||||
notifyTyping(taskId, isTyping) {
|
||||
this.emit('user:typing', {
|
||||
taskId,
|
||||
isTyping,
|
||||
projectId: store.get('currentProjectId')
|
||||
});
|
||||
}
|
||||
|
||||
// =====================
|
||||
// NOTIFICATIONS
|
||||
// =====================
|
||||
|
||||
showNotification(title, message, type = 'info') {
|
||||
window.dispatchEvent(new CustomEvent('toast:show', {
|
||||
detail: { title, message, type }
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// Create singleton instance
|
||||
const syncManager = new SyncManager();
|
||||
|
||||
// Listen for auth events
|
||||
window.addEventListener('auth:login', () => {
|
||||
syncManager.connect();
|
||||
});
|
||||
|
||||
window.addEventListener('auth:logout', () => {
|
||||
syncManager.disconnect();
|
||||
});
|
||||
|
||||
// Listen for project changes
|
||||
store.subscribe('currentProjectId', (newProjectId, oldProjectId) => {
|
||||
if (oldProjectId) {
|
||||
syncManager.leaveProject(oldProjectId);
|
||||
}
|
||||
if (newProjectId) {
|
||||
syncManager.joinProject(newProjectId);
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for online/offline events
|
||||
window.addEventListener('online', () => {
|
||||
store.setOnline(true);
|
||||
syncManager.connect();
|
||||
});
|
||||
|
||||
window.addEventListener('offline', () => {
|
||||
store.setOnline(false);
|
||||
});
|
||||
|
||||
export default syncManager;
|
||||
1465
frontend/js/task-modal.js
Normale Datei
1465
frontend/js/task-modal.js
Normale Datei
Datei-Diff unterdrückt, da er zu groß ist
Diff laden
325
frontend/js/tour.js
Normale Datei
325
frontend/js/tour.js
Normale Datei
@ -0,0 +1,325 @@
|
||||
/**
|
||||
* TASKMATE - Tour/Onboarding Module
|
||||
* ==================================
|
||||
* First-time user onboarding tour
|
||||
*/
|
||||
|
||||
import { $, createElement } from './utils.js';
|
||||
|
||||
class TourManager {
|
||||
constructor() {
|
||||
this.currentStep = 0;
|
||||
this.isActive = false;
|
||||
this.overlay = null;
|
||||
this.tooltip = null;
|
||||
|
||||
this.steps = [
|
||||
{
|
||||
target: '.view-tabs',
|
||||
title: 'Ansichten',
|
||||
content: 'Wechseln Sie zwischen Board-, Listen-, Kalender- und Dashboard-Ansicht.',
|
||||
position: 'bottom'
|
||||
},
|
||||
{
|
||||
target: '.project-selector',
|
||||
title: 'Projekte',
|
||||
content: 'Wählen Sie ein Projekt aus oder erstellen Sie ein neues.',
|
||||
position: 'bottom'
|
||||
},
|
||||
{
|
||||
target: '.column',
|
||||
title: 'Spalten',
|
||||
content: 'Spalten repräsentieren den Status Ihrer Aufgaben. Ziehen Sie Aufgaben zwischen Spalten, um den Status zu ändern.',
|
||||
position: 'right'
|
||||
},
|
||||
{
|
||||
target: '.btn-add-task',
|
||||
title: 'Neue Aufgabe',
|
||||
content: 'Klicken Sie hier, um eine neue Aufgabe zu erstellen.',
|
||||
position: 'top'
|
||||
},
|
||||
{
|
||||
target: '.filter-bar',
|
||||
title: 'Filter',
|
||||
content: 'Filtern Sie Aufgaben nach Priorität, Bearbeiter oder Fälligkeitsdatum.',
|
||||
position: 'bottom'
|
||||
},
|
||||
{
|
||||
target: '#search-input',
|
||||
title: 'Suche',
|
||||
content: 'Durchsuchen Sie alle Aufgaben nach Titel oder Beschreibung. Tipp: Drücken Sie "/" für schnellen Zugriff.',
|
||||
position: 'bottom'
|
||||
},
|
||||
{
|
||||
target: '.user-menu',
|
||||
title: 'Benutzermenu',
|
||||
content: 'Hier können Sie Ihr Passwort ändern oder sich abmelden.',
|
||||
position: 'bottom-left'
|
||||
},
|
||||
{
|
||||
target: '#theme-toggle',
|
||||
title: 'Design',
|
||||
content: 'Wechseln Sie zwischen hellem und dunklem Design.',
|
||||
position: 'bottom-left'
|
||||
}
|
||||
];
|
||||
|
||||
this.bindEvents();
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
window.addEventListener('tour:start', () => this.start());
|
||||
window.addEventListener('tour:stop', () => this.stop());
|
||||
|
||||
// Keyboard navigation
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (!this.isActive) return;
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
this.stop();
|
||||
} else if (e.key === 'ArrowRight' || e.key === 'Enter') {
|
||||
this.next();
|
||||
} else if (e.key === 'ArrowLeft') {
|
||||
this.previous();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// =====================
|
||||
// TOUR CONTROL
|
||||
// =====================
|
||||
|
||||
start() {
|
||||
// Check if tour was already completed
|
||||
if (localStorage.getItem('tour_completed') === 'true') {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isActive = true;
|
||||
this.currentStep = 0;
|
||||
|
||||
this.createOverlay();
|
||||
this.showStep();
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.isActive = false;
|
||||
|
||||
if (this.overlay) {
|
||||
this.overlay.remove();
|
||||
this.overlay = null;
|
||||
}
|
||||
|
||||
if (this.tooltip) {
|
||||
this.tooltip.remove();
|
||||
this.tooltip = null;
|
||||
}
|
||||
|
||||
// Remove highlight from any element
|
||||
$$('.tour-highlight')?.forEach(el => el.classList.remove('tour-highlight'));
|
||||
}
|
||||
|
||||
complete() {
|
||||
localStorage.setItem('tour_completed', 'true');
|
||||
this.stop();
|
||||
|
||||
window.dispatchEvent(new CustomEvent('toast:show', {
|
||||
detail: {
|
||||
message: 'Tour abgeschlossen! Viel Erfolg mit TaskMate.',
|
||||
type: 'success'
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
next() {
|
||||
if (this.currentStep < this.steps.length - 1) {
|
||||
this.currentStep++;
|
||||
this.showStep();
|
||||
} else {
|
||||
this.complete();
|
||||
}
|
||||
}
|
||||
|
||||
previous() {
|
||||
if (this.currentStep > 0) {
|
||||
this.currentStep--;
|
||||
this.showStep();
|
||||
}
|
||||
}
|
||||
|
||||
skip() {
|
||||
localStorage.setItem('tour_completed', 'true');
|
||||
this.stop();
|
||||
}
|
||||
|
||||
// =====================
|
||||
// UI CREATION
|
||||
// =====================
|
||||
|
||||
createOverlay() {
|
||||
this.overlay = createElement('div', {
|
||||
className: 'onboarding-overlay'
|
||||
});
|
||||
|
||||
document.body.appendChild(this.overlay);
|
||||
}
|
||||
|
||||
showStep() {
|
||||
const step = this.steps[this.currentStep];
|
||||
const targetElement = $(step.target);
|
||||
|
||||
if (!targetElement) {
|
||||
// Skip to next step if target not found
|
||||
this.next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove previous highlight
|
||||
$$('.tour-highlight')?.forEach(el => el.classList.remove('tour-highlight'));
|
||||
|
||||
// Highlight current target
|
||||
targetElement.classList.add('tour-highlight');
|
||||
|
||||
// Position and show tooltip
|
||||
this.showTooltip(step, targetElement);
|
||||
}
|
||||
|
||||
showTooltip(step, targetElement) {
|
||||
// Remove existing tooltip
|
||||
if (this.tooltip) {
|
||||
this.tooltip.remove();
|
||||
}
|
||||
|
||||
// Create tooltip
|
||||
this.tooltip = createElement('div', {
|
||||
className: 'onboarding-tooltip'
|
||||
});
|
||||
|
||||
// Content
|
||||
const content = createElement('div', { className: 'onboarding-content' }, [
|
||||
createElement('h3', {}, [step.title]),
|
||||
createElement('p', {}, [step.content])
|
||||
]);
|
||||
this.tooltip.appendChild(content);
|
||||
|
||||
// Footer
|
||||
const footer = createElement('div', { className: 'onboarding-footer' });
|
||||
|
||||
// Step indicator
|
||||
footer.appendChild(createElement('span', {
|
||||
id: 'onboarding-step'
|
||||
}, [`${this.currentStep + 1} / ${this.steps.length}`]));
|
||||
|
||||
// Buttons
|
||||
const buttons = createElement('div', { className: 'onboarding-buttons' });
|
||||
|
||||
if (this.currentStep > 0) {
|
||||
buttons.appendChild(createElement('button', {
|
||||
className: 'btn btn-ghost',
|
||||
onclick: () => this.previous()
|
||||
}, ['Zurück']));
|
||||
}
|
||||
|
||||
buttons.appendChild(createElement('button', {
|
||||
className: 'btn btn-ghost',
|
||||
onclick: () => this.skip()
|
||||
}, ['Überspringen']));
|
||||
|
||||
const isLast = this.currentStep === this.steps.length - 1;
|
||||
buttons.appendChild(createElement('button', {
|
||||
className: 'btn btn-primary',
|
||||
onclick: () => this.next()
|
||||
}, [isLast ? 'Fertig' : 'Weiter']));
|
||||
|
||||
footer.appendChild(buttons);
|
||||
this.tooltip.appendChild(footer);
|
||||
|
||||
document.body.appendChild(this.tooltip);
|
||||
|
||||
// Position tooltip
|
||||
this.positionTooltip(targetElement, step.position);
|
||||
}
|
||||
|
||||
positionTooltip(targetElement, position) {
|
||||
const targetRect = targetElement.getBoundingClientRect();
|
||||
const tooltipRect = this.tooltip.getBoundingClientRect();
|
||||
|
||||
const padding = 16;
|
||||
let top, left;
|
||||
|
||||
switch (position) {
|
||||
case 'top':
|
||||
top = targetRect.top - tooltipRect.height - padding;
|
||||
left = targetRect.left + (targetRect.width - tooltipRect.width) / 2;
|
||||
break;
|
||||
|
||||
case 'bottom':
|
||||
top = targetRect.bottom + padding;
|
||||
left = targetRect.left + (targetRect.width - tooltipRect.width) / 2;
|
||||
break;
|
||||
|
||||
case 'left':
|
||||
top = targetRect.top + (targetRect.height - tooltipRect.height) / 2;
|
||||
left = targetRect.left - tooltipRect.width - padding;
|
||||
break;
|
||||
|
||||
case 'right':
|
||||
top = targetRect.top + (targetRect.height - tooltipRect.height) / 2;
|
||||
left = targetRect.right + padding;
|
||||
break;
|
||||
|
||||
case 'bottom-left':
|
||||
top = targetRect.bottom + padding;
|
||||
left = targetRect.right - tooltipRect.width;
|
||||
break;
|
||||
|
||||
case 'bottom-right':
|
||||
top = targetRect.bottom + padding;
|
||||
left = targetRect.left;
|
||||
break;
|
||||
|
||||
default:
|
||||
top = targetRect.bottom + padding;
|
||||
left = targetRect.left;
|
||||
}
|
||||
|
||||
// Keep within viewport
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
if (left < padding) left = padding;
|
||||
if (left + tooltipRect.width > viewportWidth - padding) {
|
||||
left = viewportWidth - tooltipRect.width - padding;
|
||||
}
|
||||
|
||||
if (top < padding) top = padding;
|
||||
if (top + tooltipRect.height > viewportHeight - padding) {
|
||||
top = viewportHeight - tooltipRect.height - padding;
|
||||
}
|
||||
|
||||
this.tooltip.style.top = `${top}px`;
|
||||
this.tooltip.style.left = `${left}px`;
|
||||
}
|
||||
|
||||
// =====================
|
||||
// HELPERS
|
||||
// =====================
|
||||
|
||||
shouldShowTour() {
|
||||
return localStorage.getItem('tour_completed') !== 'true';
|
||||
}
|
||||
|
||||
resetTour() {
|
||||
localStorage.removeItem('tour_completed');
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function for querying multiple elements
|
||||
function $$(selector) {
|
||||
return Array.from(document.querySelectorAll(selector));
|
||||
}
|
||||
|
||||
// Create and export singleton
|
||||
const tourManager = new TourManager();
|
||||
|
||||
export default tourManager;
|
||||
332
frontend/js/undo.js
Normale Datei
332
frontend/js/undo.js
Normale Datei
@ -0,0 +1,332 @@
|
||||
/**
|
||||
* TASKMATE - Undo/Redo Module
|
||||
* ===========================
|
||||
* Undo and redo functionality
|
||||
*/
|
||||
|
||||
import store from './store.js';
|
||||
import api from './api.js';
|
||||
|
||||
class UndoManager {
|
||||
constructor() {
|
||||
this.bindEvents();
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
window.addEventListener('undo:perform', () => this.undo());
|
||||
window.addEventListener('redo:perform', () => this.redo());
|
||||
}
|
||||
|
||||
// =====================
|
||||
// UNDO/REDO
|
||||
// =====================
|
||||
|
||||
async undo() {
|
||||
if (!store.canUndo()) {
|
||||
this.showInfo('Nichts zum Rückgängigmachen');
|
||||
return;
|
||||
}
|
||||
|
||||
const action = store.popUndo();
|
||||
if (!action) return;
|
||||
|
||||
try {
|
||||
await this.executeUndo(action);
|
||||
this.showSuccess('Rückgängig gemacht');
|
||||
} catch (error) {
|
||||
console.error('Undo failed:', error);
|
||||
this.showError('Rückgängig fehlgeschlagen');
|
||||
// Re-push to undo stack
|
||||
store.pushUndo(action);
|
||||
}
|
||||
}
|
||||
|
||||
async redo() {
|
||||
if (!store.canRedo()) {
|
||||
this.showInfo('Nichts zum Wiederholen');
|
||||
return;
|
||||
}
|
||||
|
||||
const action = store.popRedo();
|
||||
if (!action) return;
|
||||
|
||||
try {
|
||||
await this.executeRedo(action);
|
||||
this.showSuccess('Wiederholt');
|
||||
} catch (error) {
|
||||
console.error('Redo failed:', error);
|
||||
this.showError('Wiederholen fehlgeschlagen');
|
||||
}
|
||||
}
|
||||
|
||||
// =====================
|
||||
// ACTION EXECUTION
|
||||
// =====================
|
||||
|
||||
async executeUndo(action) {
|
||||
const projectId = store.get('currentProjectId');
|
||||
|
||||
switch (action.type) {
|
||||
case 'DELETE_TASK':
|
||||
// Restore deleted task
|
||||
const restoredTask = await api.createTask(projectId, {
|
||||
...action.task,
|
||||
id: undefined // Let server assign new ID
|
||||
});
|
||||
store.addTask(restoredTask);
|
||||
break;
|
||||
|
||||
case 'CREATE_TASK':
|
||||
// Delete created task
|
||||
await api.deleteTask(projectId, action.taskId);
|
||||
store.removeTask(action.taskId);
|
||||
break;
|
||||
|
||||
case 'UPDATE_TASK':
|
||||
// Restore previous values
|
||||
await api.updateTask(projectId, action.taskId, action.previousData);
|
||||
store.updateTask(action.taskId, action.previousData);
|
||||
break;
|
||||
|
||||
case 'MOVE_TASK':
|
||||
// Move back to original position
|
||||
await api.moveTask(projectId, action.taskId, action.fromColumnId, action.fromPosition);
|
||||
store.moveTask(action.taskId, action.fromColumnId, action.fromPosition);
|
||||
break;
|
||||
|
||||
case 'DELETE_COLUMN':
|
||||
// Restore deleted column (without tasks - they're gone)
|
||||
const restoredColumn = await api.createColumn(projectId, action.column);
|
||||
store.addColumn(restoredColumn);
|
||||
break;
|
||||
|
||||
case 'CREATE_COLUMN':
|
||||
// Delete created column
|
||||
await api.deleteColumn(projectId, action.columnId);
|
||||
store.removeColumn(action.columnId);
|
||||
break;
|
||||
|
||||
case 'UPDATE_COLUMN':
|
||||
// Restore previous values
|
||||
await api.updateColumn(projectId, action.columnId, action.previousData);
|
||||
store.updateColumn(action.columnId, action.previousData);
|
||||
break;
|
||||
|
||||
case 'BULK_DELETE':
|
||||
// Restore all deleted tasks
|
||||
for (const task of action.tasks) {
|
||||
const restored = await api.createTask(projectId, {
|
||||
...task,
|
||||
id: undefined
|
||||
});
|
||||
store.addTask(restored);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'BULK_MOVE':
|
||||
// Move all tasks back
|
||||
for (const item of action.items) {
|
||||
await api.moveTask(projectId, item.taskId, item.fromColumnId, item.fromPosition);
|
||||
store.moveTask(item.taskId, item.fromColumnId, item.fromPosition);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'BULK_UPDATE':
|
||||
// Restore all previous values
|
||||
for (const item of action.items) {
|
||||
await api.updateTask(projectId, item.taskId, item.previousData);
|
||||
store.updateTask(item.taskId, item.previousData);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
console.warn('Unknown undo action type:', action.type);
|
||||
}
|
||||
}
|
||||
|
||||
async executeRedo(action) {
|
||||
const projectId = store.get('currentProjectId');
|
||||
|
||||
switch (action.type) {
|
||||
case 'DELETE_TASK':
|
||||
// Re-delete the task
|
||||
await api.deleteTask(projectId, action.task.id);
|
||||
store.removeTask(action.task.id);
|
||||
break;
|
||||
|
||||
case 'CREATE_TASK':
|
||||
// Re-create the task
|
||||
const recreatedTask = await api.createTask(projectId, action.taskData);
|
||||
store.addTask(recreatedTask);
|
||||
break;
|
||||
|
||||
case 'UPDATE_TASK':
|
||||
// Re-apply the update
|
||||
await api.updateTask(projectId, action.taskId, action.newData);
|
||||
store.updateTask(action.taskId, action.newData);
|
||||
break;
|
||||
|
||||
case 'MOVE_TASK':
|
||||
// Re-move to new position
|
||||
await api.moveTask(projectId, action.taskId, action.toColumnId, action.toPosition);
|
||||
store.moveTask(action.taskId, action.toColumnId, action.toPosition);
|
||||
break;
|
||||
|
||||
case 'DELETE_COLUMN':
|
||||
// Re-delete the column
|
||||
await api.deleteColumn(projectId, action.column.id);
|
||||
store.removeColumn(action.column.id);
|
||||
break;
|
||||
|
||||
case 'CREATE_COLUMN':
|
||||
// Re-create the column
|
||||
const recreatedColumn = await api.createColumn(projectId, action.columnData);
|
||||
store.addColumn(recreatedColumn);
|
||||
break;
|
||||
|
||||
case 'UPDATE_COLUMN':
|
||||
// Re-apply the update
|
||||
await api.updateColumn(projectId, action.columnId, action.newData);
|
||||
store.updateColumn(action.columnId, action.newData);
|
||||
break;
|
||||
|
||||
case 'BULK_DELETE':
|
||||
// Re-delete all tasks
|
||||
for (const task of action.tasks) {
|
||||
await api.deleteTask(projectId, task.id);
|
||||
store.removeTask(task.id);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'BULK_MOVE':
|
||||
// Re-move all tasks
|
||||
for (const item of action.items) {
|
||||
await api.moveTask(projectId, item.taskId, item.toColumnId, item.toPosition);
|
||||
store.moveTask(item.taskId, item.toColumnId, item.toPosition);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'BULK_UPDATE':
|
||||
// Re-apply all updates
|
||||
for (const item of action.items) {
|
||||
await api.updateTask(projectId, item.taskId, item.newData);
|
||||
store.updateTask(item.taskId, item.newData);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
console.warn('Unknown redo action type:', action.type);
|
||||
}
|
||||
}
|
||||
|
||||
// =====================
|
||||
// ACTION RECORDING
|
||||
// =====================
|
||||
|
||||
recordTaskDelete(task) {
|
||||
store.pushUndo({
|
||||
type: 'DELETE_TASK',
|
||||
task: { ...task }
|
||||
});
|
||||
}
|
||||
|
||||
recordTaskCreate(taskId, taskData) {
|
||||
store.pushUndo({
|
||||
type: 'CREATE_TASK',
|
||||
taskId,
|
||||
taskData
|
||||
});
|
||||
}
|
||||
|
||||
recordTaskUpdate(taskId, previousData, newData) {
|
||||
store.pushUndo({
|
||||
type: 'UPDATE_TASK',
|
||||
taskId,
|
||||
previousData,
|
||||
newData
|
||||
});
|
||||
}
|
||||
|
||||
recordTaskMove(taskId, fromColumnId, fromPosition, toColumnId, toPosition) {
|
||||
store.pushUndo({
|
||||
type: 'MOVE_TASK',
|
||||
taskId,
|
||||
fromColumnId,
|
||||
fromPosition,
|
||||
toColumnId,
|
||||
toPosition
|
||||
});
|
||||
}
|
||||
|
||||
recordColumnDelete(column) {
|
||||
store.pushUndo({
|
||||
type: 'DELETE_COLUMN',
|
||||
column: { ...column }
|
||||
});
|
||||
}
|
||||
|
||||
recordColumnCreate(columnId, columnData) {
|
||||
store.pushUndo({
|
||||
type: 'CREATE_COLUMN',
|
||||
columnId,
|
||||
columnData
|
||||
});
|
||||
}
|
||||
|
||||
recordColumnUpdate(columnId, previousData, newData) {
|
||||
store.pushUndo({
|
||||
type: 'UPDATE_COLUMN',
|
||||
columnId,
|
||||
previousData,
|
||||
newData
|
||||
});
|
||||
}
|
||||
|
||||
recordBulkDelete(tasks) {
|
||||
store.pushUndo({
|
||||
type: 'BULK_DELETE',
|
||||
tasks: tasks.map(t => ({ ...t }))
|
||||
});
|
||||
}
|
||||
|
||||
recordBulkMove(items) {
|
||||
store.pushUndo({
|
||||
type: 'BULK_MOVE',
|
||||
items: [...items]
|
||||
});
|
||||
}
|
||||
|
||||
recordBulkUpdate(items) {
|
||||
store.pushUndo({
|
||||
type: 'BULK_UPDATE',
|
||||
items: [...items]
|
||||
});
|
||||
}
|
||||
|
||||
// =====================
|
||||
// HELPERS
|
||||
// =====================
|
||||
|
||||
showSuccess(message) {
|
||||
window.dispatchEvent(new CustomEvent('toast:show', {
|
||||
detail: { message, type: 'success' }
|
||||
}));
|
||||
}
|
||||
|
||||
showError(message) {
|
||||
window.dispatchEvent(new CustomEvent('toast:show', {
|
||||
detail: { message, type: 'error' }
|
||||
}));
|
||||
}
|
||||
|
||||
showInfo(message) {
|
||||
window.dispatchEvent(new CustomEvent('toast:show', {
|
||||
detail: { message, type: 'info' }
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// Create and export singleton
|
||||
const undoManager = new UndoManager();
|
||||
|
||||
export default undoManager;
|
||||
615
frontend/js/utils.js
Normale Datei
615
frontend/js/utils.js
Normale Datei
@ -0,0 +1,615 @@
|
||||
/**
|
||||
* TASKMATE - Utility Functions
|
||||
* ============================
|
||||
*/
|
||||
|
||||
// Date Formatting
|
||||
export function formatDate(dateString, options = {}) {
|
||||
if (!dateString) return '';
|
||||
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
|
||||
if (options.relative) {
|
||||
const diff = date - now;
|
||||
const days = Math.ceil(diff / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (days === 0) return 'Heute';
|
||||
if (days === 1) return 'Morgen';
|
||||
if (days === -1) return 'Gestern';
|
||||
if (days > 0 && days <= 7) return `In ${days} Tagen`;
|
||||
if (days < 0 && days >= -7) return `Vor ${Math.abs(days)} Tagen`;
|
||||
}
|
||||
|
||||
return date.toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: options.year ? 'numeric' : undefined,
|
||||
...options
|
||||
});
|
||||
}
|
||||
|
||||
export function formatDateTime(dateString) {
|
||||
if (!dateString) return '';
|
||||
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
export function formatTime(dateString) {
|
||||
if (!dateString) return '';
|
||||
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleTimeString('de-DE', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
export function formatRelativeTime(dateString) {
|
||||
if (!dateString) return '';
|
||||
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diff = now - date;
|
||||
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
const hours = Math.floor(diff / 3600000);
|
||||
const days = Math.floor(diff / 86400000);
|
||||
|
||||
if (minutes < 1) return 'Gerade eben';
|
||||
if (minutes < 60) return `Vor ${minutes} Min.`;
|
||||
if (hours < 24) return `Vor ${hours} Std.`;
|
||||
if (days < 7) return `Vor ${days} Tagen`;
|
||||
|
||||
return formatDate(dateString, { year: true });
|
||||
}
|
||||
|
||||
// Time Duration
|
||||
export function formatDuration(hours, minutes) {
|
||||
const parts = [];
|
||||
if (hours > 0) parts.push(`${hours}h`);
|
||||
if (minutes > 0) parts.push(`${minutes}m`);
|
||||
return parts.join(' ') || '0m';
|
||||
}
|
||||
|
||||
export function parseDuration(durationString) {
|
||||
const match = durationString.match(/(\d+)h\s*(\d+)?m?|(\d+)m/);
|
||||
if (!match) return { hours: 0, minutes: 0 };
|
||||
|
||||
if (match[1]) {
|
||||
return {
|
||||
hours: parseInt(match[1]) || 0,
|
||||
minutes: parseInt(match[2]) || 0
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
hours: 0,
|
||||
minutes: parseInt(match[3]) || 0
|
||||
};
|
||||
}
|
||||
|
||||
// File Size
|
||||
export function formatFileSize(bytes) {
|
||||
if (bytes === 0) return '0 B';
|
||||
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
const k = 1024;
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + units[i];
|
||||
}
|
||||
|
||||
// String Utilities
|
||||
export function truncate(str, length = 50) {
|
||||
if (!str || str.length <= length) return str;
|
||||
return str.substring(0, length) + '...';
|
||||
}
|
||||
|
||||
export function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
export function slugify(str) {
|
||||
return str
|
||||
.toLowerCase()
|
||||
.replace(/[äöüß]/g, match => ({
|
||||
'ä': 'ae', 'ö': 'oe', 'ü': 'ue', 'ß': 'ss'
|
||||
}[match]))
|
||||
.replace(/[^\w\s-]/g, '')
|
||||
.replace(/[\s_-]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
export function getInitials(name) {
|
||||
if (!name) return '?';
|
||||
|
||||
return name
|
||||
.split(' ')
|
||||
.map(part => part.charAt(0).toUpperCase())
|
||||
.slice(0, 2)
|
||||
.join('');
|
||||
}
|
||||
|
||||
// Color Utilities
|
||||
export function hexToRgba(hex, alpha = 1) {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
if (!result) return hex;
|
||||
|
||||
return `rgba(${parseInt(result[1], 16)}, ${parseInt(result[2], 16)}, ${parseInt(result[3], 16)}, ${alpha})`;
|
||||
}
|
||||
|
||||
export function getContrastColor(hexColor) {
|
||||
const rgb = parseInt(hexColor.slice(1), 16);
|
||||
const r = (rgb >> 16) & 0xff;
|
||||
const g = (rgb >> 8) & 0xff;
|
||||
const b = (rgb >> 0) & 0xff;
|
||||
|
||||
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
||||
return luminance > 0.5 ? '#000000' : '#FFFFFF';
|
||||
}
|
||||
|
||||
// DOM Utilities
|
||||
export function $(selector, context = document) {
|
||||
return context.querySelector(selector);
|
||||
}
|
||||
|
||||
export function $$(selector, context = document) {
|
||||
return Array.from(context.querySelectorAll(selector));
|
||||
}
|
||||
|
||||
export function createElement(tag, attributes = {}, children = []) {
|
||||
const element = document.createElement(tag);
|
||||
|
||||
Object.entries(attributes).forEach(([key, value]) => {
|
||||
if (key === 'className') {
|
||||
element.className = value;
|
||||
} else if (key === 'dataset') {
|
||||
Object.entries(value).forEach(([dataKey, dataValue]) => {
|
||||
element.dataset[dataKey] = dataValue;
|
||||
});
|
||||
} else if (key === 'style' && typeof value === 'object') {
|
||||
Object.assign(element.style, value);
|
||||
} else if (key.startsWith('on') && typeof value === 'function') {
|
||||
element.addEventListener(key.slice(2).toLowerCase(), value);
|
||||
} else if (key === 'checked' || key === 'disabled' || key === 'selected') {
|
||||
// Boolean properties must be set as properties, not attributes
|
||||
element[key] = value;
|
||||
} else {
|
||||
element.setAttribute(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
children.forEach(child => {
|
||||
if (typeof child === 'string') {
|
||||
element.appendChild(document.createTextNode(child));
|
||||
} else if (child instanceof Node) {
|
||||
element.appendChild(child);
|
||||
}
|
||||
});
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
export function removeElement(element) {
|
||||
if (element && element.parentNode) {
|
||||
element.parentNode.removeChild(element);
|
||||
}
|
||||
}
|
||||
|
||||
export function clearElement(element) {
|
||||
while (element.firstChild) {
|
||||
element.removeChild(element.firstChild);
|
||||
}
|
||||
}
|
||||
|
||||
// Event Utilities
|
||||
export function debounce(func, wait = 300) {
|
||||
let timeout;
|
||||
|
||||
return function executedFunction(...args) {
|
||||
const later = () => {
|
||||
clearTimeout(timeout);
|
||||
func(...args);
|
||||
};
|
||||
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
};
|
||||
}
|
||||
|
||||
export function throttle(func, limit = 100) {
|
||||
let inThrottle;
|
||||
|
||||
return function executedFunction(...args) {
|
||||
if (!inThrottle) {
|
||||
func(...args);
|
||||
inThrottle = true;
|
||||
setTimeout(() => inThrottle = false, limit);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function onClickOutside(element, callback) {
|
||||
const handler = (event) => {
|
||||
if (!element.contains(event.target)) {
|
||||
callback(event);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('click', handler);
|
||||
|
||||
return () => document.removeEventListener('click', handler);
|
||||
}
|
||||
|
||||
// Array Utilities
|
||||
export function moveInArray(array, fromIndex, toIndex) {
|
||||
const element = array[fromIndex];
|
||||
array.splice(fromIndex, 1);
|
||||
array.splice(toIndex, 0, element);
|
||||
return array;
|
||||
}
|
||||
|
||||
export function groupBy(array, key) {
|
||||
return array.reduce((groups, item) => {
|
||||
const value = typeof key === 'function' ? key(item) : item[key];
|
||||
(groups[value] = groups[value] || []).push(item);
|
||||
return groups;
|
||||
}, {});
|
||||
}
|
||||
|
||||
export function sortBy(array, key, direction = 'asc') {
|
||||
const modifier = direction === 'desc' ? -1 : 1;
|
||||
|
||||
return [...array].sort((a, b) => {
|
||||
const aValue = typeof key === 'function' ? key(a) : a[key];
|
||||
const bValue = typeof key === 'function' ? key(b) : b[key];
|
||||
|
||||
if (aValue < bValue) return -1 * modifier;
|
||||
if (aValue > bValue) return 1 * modifier;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
// Object Utilities
|
||||
export function deepClone(obj) {
|
||||
return JSON.parse(JSON.stringify(obj));
|
||||
}
|
||||
|
||||
export function deepMerge(target, source) {
|
||||
const output = { ...target };
|
||||
|
||||
Object.keys(source).forEach(key => {
|
||||
if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
|
||||
if (target[key] && typeof target[key] === 'object') {
|
||||
output[key] = deepMerge(target[key], source[key]);
|
||||
} else {
|
||||
output[key] = { ...source[key] };
|
||||
}
|
||||
} else {
|
||||
output[key] = source[key];
|
||||
}
|
||||
});
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
// URL Utilities
|
||||
export function getUrlDomain(url) {
|
||||
try {
|
||||
return new URL(url).hostname;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
export function isValidUrl(string) {
|
||||
try {
|
||||
new URL(string);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function getFileExtension(filename) {
|
||||
return filename.split('.').pop().toLowerCase();
|
||||
}
|
||||
|
||||
export function isImageFile(filename) {
|
||||
const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp'];
|
||||
return imageExtensions.includes(getFileExtension(filename));
|
||||
}
|
||||
|
||||
// ID Generation
|
||||
export function generateId() {
|
||||
return Date.now().toString(36) + Math.random().toString(36).substr(2);
|
||||
}
|
||||
|
||||
export function generateTempId() {
|
||||
return 'temp_' + generateId();
|
||||
}
|
||||
|
||||
// Local Storage
|
||||
export function getFromStorage(key, defaultValue = null) {
|
||||
try {
|
||||
const item = localStorage.getItem(key);
|
||||
return item ? JSON.parse(item) : defaultValue;
|
||||
} catch {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
export function setToStorage(key, value) {
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function removeFromStorage(key) {
|
||||
try {
|
||||
localStorage.removeItem(key);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Priority Stars
|
||||
export function getPriorityStars(priority) {
|
||||
const config = {
|
||||
high: { stars: '★★★', label: 'Hohe Priorität', colorClass: 'priority-high' },
|
||||
medium: { stars: '★★', label: 'Mittlere Priorität', colorClass: 'priority-medium' },
|
||||
low: { stars: '★', label: 'Niedrige Priorität', colorClass: 'priority-low' }
|
||||
};
|
||||
return config[priority] || config.medium;
|
||||
}
|
||||
|
||||
export function createPriorityElement(priority) {
|
||||
const config = getPriorityStars(priority);
|
||||
const span = document.createElement('span');
|
||||
span.className = `priority-stars ${config.colorClass}`;
|
||||
span.textContent = config.stars;
|
||||
span.title = config.label;
|
||||
return span;
|
||||
}
|
||||
|
||||
// Due Date Status
|
||||
export function getDueDateStatus(dueDate) {
|
||||
if (!dueDate) return null;
|
||||
|
||||
const due = new Date(dueDate);
|
||||
const now = new Date();
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const dueDay = new Date(due.getFullYear(), due.getMonth(), due.getDate());
|
||||
|
||||
const diffDays = Math.ceil((dueDay - today) / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays < 0) return 'overdue';
|
||||
if (diffDays === 0) return 'today';
|
||||
if (diffDays <= 2) return 'soon';
|
||||
return 'normal';
|
||||
}
|
||||
|
||||
// Progress Calculation
|
||||
export function calculateProgress(subtasks) {
|
||||
if (!subtasks || subtasks.length === 0) return null;
|
||||
|
||||
const completed = subtasks.filter(st => st.completed).length;
|
||||
return {
|
||||
completed,
|
||||
total: subtasks.length,
|
||||
percentage: Math.round((completed / subtasks.length) * 100)
|
||||
};
|
||||
}
|
||||
|
||||
// Search/Filter Helpers
|
||||
export function matchesSearch(text, query) {
|
||||
if (!query) return true;
|
||||
if (!text) return false;
|
||||
|
||||
return text.toLowerCase().includes(query.toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep search in task content - searches in title, description, subtasks, and labels
|
||||
* @param {Object} task - The task object to search in
|
||||
* @param {string} query - The search query
|
||||
* @returns {boolean} - True if query matches any content
|
||||
*/
|
||||
export function searchInTaskContent(task, query) {
|
||||
if (!query || !query.trim()) return true;
|
||||
|
||||
const searchQuery = query.toLowerCase().trim();
|
||||
|
||||
// Search in title
|
||||
if (task.title && task.title.toLowerCase().includes(searchQuery)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Search in description
|
||||
if (task.description && task.description.toLowerCase().includes(searchQuery)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Search in subtasks
|
||||
if (task.subtasks && Array.isArray(task.subtasks)) {
|
||||
for (const subtask of task.subtasks) {
|
||||
if (subtask.title && subtask.title.toLowerCase().includes(searchQuery)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Search in labels
|
||||
if (task.labels && Array.isArray(task.labels)) {
|
||||
for (const label of task.labels) {
|
||||
if (label.name && label.name.toLowerCase().includes(searchQuery)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Search in assigned user name
|
||||
if (task.assignedName && task.assignedName.toLowerCase().includes(searchQuery)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function filterTasks(tasks, filters, searchResultIds = [], columns = []) {
|
||||
// Hilfsfunktion: Prüfen ob Aufgabe in der letzten Spalte (erledigt) ist
|
||||
const isTaskCompleted = (task) => {
|
||||
if (!columns || columns.length === 0) return false;
|
||||
const lastColumnId = columns[columns.length - 1].id;
|
||||
return task.columnId === lastColumnId;
|
||||
};
|
||||
|
||||
return tasks.filter(task => {
|
||||
// Search query - use deep search
|
||||
// But allow tasks that were found by server search (for deep content like attachments)
|
||||
if (filters.search) {
|
||||
const isServerResult = searchResultIds.includes(task.id);
|
||||
const matchesClient = searchInTaskContent(task, filters.search);
|
||||
|
||||
if (!isServerResult && !matchesClient) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Priority filter
|
||||
if (filters.priority && filters.priority !== 'all') {
|
||||
if (task.priority !== filters.priority) return false;
|
||||
}
|
||||
|
||||
// Assignee filter
|
||||
if (filters.assignee && filters.assignee !== 'all') {
|
||||
if (task.assignedTo !== parseInt(filters.assignee)) return false;
|
||||
}
|
||||
|
||||
// Label filter
|
||||
if (filters.label && filters.label !== 'all') {
|
||||
const hasLabel = task.labels?.some(l => l.id === parseInt(filters.label));
|
||||
if (!hasLabel) return false;
|
||||
}
|
||||
|
||||
// Due date filter
|
||||
if (filters.dueDate && filters.dueDate !== 'all' && filters.dueDate !== '') {
|
||||
const status = getDueDateStatus(task.dueDate);
|
||||
|
||||
// Bei "überfällig" erledigte Aufgaben ausschließen
|
||||
if (filters.dueDate === 'overdue') {
|
||||
if (status !== 'overdue' || isTaskCompleted(task)) return false;
|
||||
}
|
||||
if (filters.dueDate === 'today' && status !== 'today') return false;
|
||||
if (filters.dueDate === 'week') {
|
||||
const due = new Date(task.dueDate);
|
||||
const weekFromNow = new Date();
|
||||
weekFromNow.setDate(weekFromNow.getDate() + 7);
|
||||
if (!task.dueDate || due > weekFromNow) return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// Clipboard
|
||||
export async function copyToClipboard(text) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch {
|
||||
// Fallback
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.opacity = '0';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
return true;
|
||||
} finally {
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Keyboard Helpers
|
||||
export function getKeyCombo(event) {
|
||||
const parts = [];
|
||||
|
||||
if (event.ctrlKey || event.metaKey) parts.push('Ctrl');
|
||||
if (event.altKey) parts.push('Alt');
|
||||
if (event.shiftKey) parts.push('Shift');
|
||||
|
||||
if (event.key && !['Control', 'Alt', 'Shift', 'Meta'].includes(event.key)) {
|
||||
parts.push(event.key.toUpperCase());
|
||||
}
|
||||
|
||||
return parts.join('+');
|
||||
}
|
||||
|
||||
// Focus Management
|
||||
export function trapFocus(element) {
|
||||
const focusableElements = element.querySelectorAll(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||
);
|
||||
|
||||
const firstFocusable = focusableElements[0];
|
||||
const lastFocusable = focusableElements[focusableElements.length - 1];
|
||||
|
||||
const handleKeydown = (e) => {
|
||||
if (e.key !== 'Tab') return;
|
||||
|
||||
if (e.shiftKey) {
|
||||
if (document.activeElement === firstFocusable) {
|
||||
lastFocusable.focus();
|
||||
e.preventDefault();
|
||||
}
|
||||
} else {
|
||||
if (document.activeElement === lastFocusable) {
|
||||
firstFocusable.focus();
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
element.addEventListener('keydown', handleKeydown);
|
||||
|
||||
return () => element.removeEventListener('keydown', handleKeydown);
|
||||
}
|
||||
|
||||
// Announcements for Screen Readers
|
||||
export function announce(message, priority = 'polite') {
|
||||
const announcer = document.getElementById('sr-announcer') || createAnnouncer();
|
||||
announcer.setAttribute('aria-live', priority);
|
||||
announcer.textContent = message;
|
||||
}
|
||||
|
||||
function createAnnouncer() {
|
||||
const announcer = document.createElement('div');
|
||||
announcer.id = 'sr-announcer';
|
||||
announcer.setAttribute('aria-live', 'polite');
|
||||
announcer.setAttribute('aria-atomic', 'true');
|
||||
announcer.className = 'sr-only';
|
||||
document.body.appendChild(announcer);
|
||||
return announcer;
|
||||
}
|
||||
291
frontend/sw.js
Normale Datei
291
frontend/sw.js
Normale Datei
@ -0,0 +1,291 @@
|
||||
/**
|
||||
* TASKMATE - Service Worker
|
||||
* ==========================
|
||||
* Offline support and caching
|
||||
*/
|
||||
|
||||
const CACHE_VERSION = '118';
|
||||
const CACHE_NAME = 'taskmate-v' + CACHE_VERSION;
|
||||
const STATIC_CACHE_NAME = 'taskmate-static-v' + CACHE_VERSION;
|
||||
const DYNAMIC_CACHE_NAME = 'taskmate-dynamic-v' + CACHE_VERSION;
|
||||
|
||||
// Files to cache immediately
|
||||
const STATIC_ASSETS = [
|
||||
'/',
|
||||
'/index.html',
|
||||
'/css/variables.css',
|
||||
'/css/base.css',
|
||||
'/css/components.css',
|
||||
'/css/board.css',
|
||||
'/css/modal.css',
|
||||
'/css/calendar.css',
|
||||
'/css/responsive.css',
|
||||
'/js/app.js',
|
||||
'/js/utils.js',
|
||||
'/js/api.js',
|
||||
'/js/auth.js',
|
||||
'/js/store.js',
|
||||
'/js/sync.js',
|
||||
'/js/offline.js',
|
||||
'/js/board.js',
|
||||
'/js/task-modal.js',
|
||||
'/js/calendar.js',
|
||||
'/js/list.js',
|
||||
'/js/shortcuts.js',
|
||||
'/js/undo.js',
|
||||
'/js/tour.js',
|
||||
'/js/admin.js',
|
||||
'/js/proposals.js',
|
||||
'/js/notifications.js',
|
||||
'/js/gitea.js',
|
||||
'/css/list.css',
|
||||
'/css/admin.css',
|
||||
'/css/proposals.css',
|
||||
'/css/notifications.css',
|
||||
'/css/gitea.css'
|
||||
];
|
||||
|
||||
// API routes to cache
|
||||
const API_CACHE_ROUTES = [
|
||||
'/api/projects',
|
||||
'/api/auth/users'
|
||||
];
|
||||
|
||||
// Install event - cache static assets
|
||||
self.addEventListener('install', (event) => {
|
||||
console.log('[SW] Installing...');
|
||||
|
||||
event.waitUntil(
|
||||
caches.open(STATIC_CACHE_NAME)
|
||||
.then((cache) => {
|
||||
console.log('[SW] Caching static assets');
|
||||
return cache.addAll(STATIC_ASSETS);
|
||||
})
|
||||
.then(() => self.skipWaiting())
|
||||
);
|
||||
});
|
||||
|
||||
// Activate event - clean up old caches
|
||||
self.addEventListener('activate', (event) => {
|
||||
console.log('[SW] Activating...');
|
||||
|
||||
event.waitUntil(
|
||||
caches.keys().then((cacheNames) => {
|
||||
return Promise.all(
|
||||
cacheNames
|
||||
.filter((name) => {
|
||||
return name.startsWith('taskmate-') &&
|
||||
name !== STATIC_CACHE_NAME &&
|
||||
name !== DYNAMIC_CACHE_NAME;
|
||||
})
|
||||
.map((name) => {
|
||||
console.log('[SW] Deleting old cache:', name);
|
||||
return caches.delete(name);
|
||||
})
|
||||
);
|
||||
}).then(() => self.clients.claim())
|
||||
);
|
||||
});
|
||||
|
||||
// Fetch event - serve from cache or network
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const url = new URL(event.request.url);
|
||||
|
||||
// Skip non-GET requests
|
||||
if (event.request.method !== 'GET') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip WebSocket requests
|
||||
if (url.protocol === 'ws:' || url.protocol === 'wss:') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle API requests
|
||||
if (url.pathname.startsWith('/api/')) {
|
||||
event.respondWith(handleApiRequest(event.request));
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle static assets
|
||||
event.respondWith(handleStaticRequest(event.request));
|
||||
});
|
||||
|
||||
// Handle static asset requests - Network First strategy
|
||||
async function handleStaticRequest(request) {
|
||||
// Try network first to always get fresh content
|
||||
try {
|
||||
const networkResponse = await fetch(request);
|
||||
|
||||
// Cache successful responses
|
||||
if (networkResponse.ok) {
|
||||
const cache = await caches.open(STATIC_CACHE_NAME);
|
||||
cache.put(request, networkResponse.clone());
|
||||
}
|
||||
|
||||
return networkResponse;
|
||||
} catch (error) {
|
||||
// If network fails, try cache
|
||||
const cachedResponse = await caches.match(request);
|
||||
if (cachedResponse) {
|
||||
console.log('[SW] Serving from cache (offline):', request.url);
|
||||
return cachedResponse;
|
||||
}
|
||||
|
||||
// Return offline page if available for navigation
|
||||
if (request.mode === 'navigate') {
|
||||
const offlinePage = await caches.match('/index.html');
|
||||
if (offlinePage) {
|
||||
return offlinePage;
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle API requests
|
||||
async function handleApiRequest(request) {
|
||||
const url = new URL(request.url);
|
||||
|
||||
// Check if this is a cacheable API route
|
||||
const isCacheable = API_CACHE_ROUTES.some(route =>
|
||||
url.pathname.startsWith(route)
|
||||
);
|
||||
|
||||
// Try network first for API requests
|
||||
try {
|
||||
const networkResponse = await fetch(request);
|
||||
|
||||
// Cache successful GET responses for cacheable routes
|
||||
if (networkResponse.ok && isCacheable) {
|
||||
const cache = await caches.open(DYNAMIC_CACHE_NAME);
|
||||
cache.put(request, networkResponse.clone());
|
||||
}
|
||||
|
||||
return networkResponse;
|
||||
} catch (error) {
|
||||
// If offline, try to return cached response
|
||||
if (isCacheable) {
|
||||
const cachedResponse = await caches.match(request);
|
||||
if (cachedResponse) {
|
||||
console.log('[SW] Serving cached API response:', request.url);
|
||||
return cachedResponse;
|
||||
}
|
||||
}
|
||||
|
||||
// Return error response
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: 'Keine Internetverbindung',
|
||||
offline: true
|
||||
}),
|
||||
{
|
||||
status: 503,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Background sync
|
||||
self.addEventListener('sync', (event) => {
|
||||
console.log('[SW] Background sync:', event.tag);
|
||||
|
||||
if (event.tag === 'sync-pending') {
|
||||
event.waitUntil(syncPendingOperations());
|
||||
}
|
||||
});
|
||||
|
||||
// Sync pending operations
|
||||
async function syncPendingOperations() {
|
||||
// This will be handled by the main app
|
||||
// Just notify clients that sync is needed
|
||||
const clients = await self.clients.matchAll();
|
||||
clients.forEach(client => {
|
||||
client.postMessage({
|
||||
type: 'SYNC_NEEDED'
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Push notifications (for future use)
|
||||
self.addEventListener('push', (event) => {
|
||||
if (!event.data) return;
|
||||
|
||||
const data = event.data.json();
|
||||
|
||||
const options = {
|
||||
body: data.body,
|
||||
icon: '/icons/icon-192.png',
|
||||
badge: '/icons/badge-72.png',
|
||||
vibrate: [100, 50, 100],
|
||||
data: {
|
||||
url: data.url || '/'
|
||||
},
|
||||
actions: [
|
||||
{ action: 'open', title: 'Öffnen' },
|
||||
{ action: 'close', title: 'Schließen' }
|
||||
]
|
||||
};
|
||||
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(data.title || 'TaskMate', options)
|
||||
);
|
||||
});
|
||||
|
||||
// Notification click
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
event.notification.close();
|
||||
|
||||
if (event.action === 'close') {
|
||||
return;
|
||||
}
|
||||
|
||||
const url = event.notification.data?.url || '/';
|
||||
|
||||
event.waitUntil(
|
||||
self.clients.matchAll({ type: 'window' }).then((clients) => {
|
||||
// Check if there's already a window open
|
||||
for (const client of clients) {
|
||||
if (client.url === url && 'focus' in client) {
|
||||
return client.focus();
|
||||
}
|
||||
}
|
||||
|
||||
// Open new window if none found
|
||||
if (self.clients.openWindow) {
|
||||
return self.clients.openWindow(url);
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Message handling
|
||||
self.addEventListener('message', (event) => {
|
||||
console.log('[SW] Message received:', event.data);
|
||||
|
||||
if (event.data.type === 'SKIP_WAITING') {
|
||||
self.skipWaiting();
|
||||
}
|
||||
|
||||
if (event.data.type === 'CACHE_URLS') {
|
||||
event.waitUntil(
|
||||
caches.open(DYNAMIC_CACHE_NAME).then((cache) => {
|
||||
return cache.addAll(event.data.urls);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (event.data.type === 'CLEAR_CACHE') {
|
||||
event.waitUntil(
|
||||
caches.keys().then((cacheNames) => {
|
||||
return Promise.all(
|
||||
cacheNames.map((name) => caches.delete(name))
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('[SW] Service Worker loaded');
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren