Dieser Commit ist enthalten in:
Claude Project Manager
2025-12-28 21:36:45 +00:00
Commit ab1e5be9a9
146 geänderte Dateien mit 65525 neuen und 0 gelöschten Zeilen

540
frontend/css/admin.css Normale Datei
Datei anzeigen

@ -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
Datei anzeigen

@ -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

Datei-Diff unterdrückt, da er zu groß ist Diff laden

763
frontend/css/calendar.css Normale Datei
Datei anzeigen

@ -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
Datei anzeigen

@ -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
Datei anzeigen

@ -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
Datei anzeigen

@ -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
Datei anzeigen

@ -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
Datei anzeigen

@ -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
Datei anzeigen

@ -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
Datei anzeigen

@ -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
Datei anzeigen

@ -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
Datei anzeigen

@ -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

Datei-Diff unterdrückt, da er zu groß ist Diff laden

505
frontend/js/admin.js Normale Datei
Datei anzeigen

@ -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
Datei anzeigen

@ -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

Datei-Diff unterdrückt, da er zu groß ist Diff laden

546
frontend/js/auth.js Normale Datei
Datei anzeigen

@ -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

Datei-Diff unterdrückt, da er zu groß ist Diff laden

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
Datei anzeigen

@ -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
Datei anzeigen

@ -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
Datei anzeigen

@ -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
Datei anzeigen

@ -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
Datei anzeigen

@ -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
Datei anzeigen

@ -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
Datei anzeigen

@ -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
Datei anzeigen

@ -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
Datei anzeigen

@ -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

Datei-Diff unterdrückt, da er zu groß ist Diff laden

325
frontend/js/tour.js Normale Datei
Datei anzeigen

@ -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
Datei anzeigen

@ -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
Datei anzeigen

@ -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
Datei anzeigen

@ -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');