made installer and seperated stuff into diferent files
This commit is contained in:
@@ -1,380 +1,3 @@
|
||||
.viewer-placeholder {
|
||||
min-height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px dashed var(--bs-border-color);
|
||||
border-radius: 8px;
|
||||
color: var(--bs-secondary-color);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.viewer-placeholder h1 {
|
||||
color: var(--bs-body-color);
|
||||
}
|
||||
|
||||
.task-list-view,
|
||||
.project-view,
|
||||
.version-view {
|
||||
max-width: 1120px;
|
||||
}
|
||||
|
||||
.task-list-header,
|
||||
.version-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.task-list-header h1 {
|
||||
margin: 0;
|
||||
color: var(--bs-body-color);
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.version-title {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin: -4px -6px 0;
|
||||
padding: 4px 6px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--bs-body-color);
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.viewer-key {
|
||||
margin-bottom: 4px;
|
||||
color: var(--bs-secondary-color);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin: 0 0 14px;
|
||||
color: var(--bs-body-color);
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.viewer-description {
|
||||
min-height: 72px;
|
||||
color: var(--bs-body-color);
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.version-detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 340px;
|
||||
gap: 32px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.version-panel {
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.version-task-section {
|
||||
max-width: 1080px;
|
||||
}
|
||||
|
||||
.version-task-table {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.version-task-row {
|
||||
display: grid;
|
||||
grid-template-columns:
|
||||
minmax(92px, 0.7fr)
|
||||
minmax(180px, 1.5fr)
|
||||
minmax(120px, 0.9fr)
|
||||
minmax(130px, 0.9fr)
|
||||
minmax(150px, 1.1fr)
|
||||
minmax(120px, 0.85fr);
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.version-task-head {
|
||||
border-bottom: 1px solid var(--bs-border-color);
|
||||
background: var(--bs-secondary-bg);
|
||||
}
|
||||
|
||||
.version-task-head button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 7px;
|
||||
min-height: 42px;
|
||||
padding: 0 14px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--bs-secondary-color);
|
||||
text-align: left;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.version-task-head button:hover,
|
||||
.version-task-head button:focus,
|
||||
.version-task-head button.is-active {
|
||||
color: var(--bs-body-color);
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.version-task-head button i {
|
||||
width: 12px;
|
||||
color: inherit;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.version-task-item {
|
||||
min-height: 50px;
|
||||
padding: 8px 14px;
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--bs-border-color);
|
||||
background: var(--bs-body-bg);
|
||||
color: var(--bs-body-color);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.version-task-item:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.version-task-item:hover,
|
||||
.version-task-item:focus {
|
||||
background: var(--bs-secondary-bg);
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.version-task-id {
|
||||
color: var(--bs-secondary-color);
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.version-task-title {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.version-task-loading {
|
||||
padding: 14px;
|
||||
color: var(--bs-secondary-color);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.version-task-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
min-height: 26px;
|
||||
padding: 3px 8px;
|
||||
border: 1px solid color-mix(in srgb, var(--task-status-color, var(--color-accent)) 55%, var(--bs-border-color));
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--task-status-color, var(--color-accent)) 16%, transparent);
|
||||
color: var(--bs-body-color);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.version-task-pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
min-height: 36px;
|
||||
margin-top: 10px;
|
||||
color: var(--bs-secondary-color);
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.version-task-page-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.version-task-page-actions .btn {
|
||||
display: inline-grid;
|
||||
width: 32px;
|
||||
height: 30px;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.version-inline-field {
|
||||
min-height: 34px;
|
||||
padding: 5px 7px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.version-title:hover,
|
||||
.version-title:focus,
|
||||
.version-inline-field:hover,
|
||||
.version-inline-field:focus {
|
||||
border-color: var(--bs-border-color);
|
||||
background: var(--bs-secondary-bg);
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.version-title.is-readonly:hover,
|
||||
.version-title.is-readonly:focus,
|
||||
.version-inline-field.is-readonly:hover,
|
||||
.version-inline-field.is-readonly:focus {
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.version-inline-field.is-empty {
|
||||
color: var(--bs-secondary-color);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.version-inline-field.is-saving {
|
||||
opacity: 0.65;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.version-inline-form {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.version-inline-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.version-inline-actions .btn {
|
||||
display: inline-grid;
|
||||
width: 36px;
|
||||
height: 32px;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.version-inline-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.meta-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.meta-row {
|
||||
display: grid;
|
||||
grid-template-columns: 120px minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.meta-label {
|
||||
color: var(--bs-secondary-color);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.meta-value {
|
||||
min-width: 0;
|
||||
color: var(--bs-body-color);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.profile-view {
|
||||
max-width: 860px;
|
||||
}
|
||||
|
||||
.profile-header {
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.profile-header h1 {
|
||||
margin: 0;
|
||||
color: var(--bs-body-color);
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.profile-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 240px minmax(0, 1fr);
|
||||
gap: 32px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.profile-avatar-panel,
|
||||
.profile-form-panel {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.profile-avatar-preview {
|
||||
display: grid;
|
||||
width: 132px;
|
||||
height: 132px;
|
||||
margin-bottom: 14px;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 999px;
|
||||
background: var(--bs-secondary-bg);
|
||||
}
|
||||
|
||||
.profile-avatar-preview img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.profile-avatar-fallback {
|
||||
color: var(--bs-secondary-color);
|
||||
font-size: 42px;
|
||||
}
|
||||
|
||||
.profile-picture-button {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.profile-remove-picture {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--bs-secondary-color);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.profile-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.profile-status {
|
||||
color: var(--bs-secondary-color);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.admin-view {
|
||||
max-width: 1120px;
|
||||
}
|
||||
@@ -812,24 +435,6 @@
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.task-list-header,
|
||||
.version-header {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.meta-row {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.version-detail-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.profile-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.admin-layout {
|
||||
grid-template-columns: 1fr;
|
||||
@@ -854,13 +459,4 @@
|
||||
.admin-custom-field-item {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.version-task-table {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.version-task-row {
|
||||
grid-template-columns: 100px 190px 130px 140px 170px 130px;
|
||||
min-width: 870px;
|
||||
}
|
||||
}
|
||||
13
ProjectKiln/app/css/viewer/dashboard.css
Normal file
13
ProjectKiln/app/css/viewer/dashboard.css
Normal file
@@ -0,0 +1,13 @@
|
||||
.viewer-placeholder {
|
||||
min-height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px dashed var(--bs-border-color);
|
||||
border-radius: 8px;
|
||||
color: var(--bs-secondary-color);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.viewer-placeholder h1 {
|
||||
color: var(--bs-body-color);
|
||||
}
|
||||
81
ProjectKiln/app/css/viewer/profile.css
Normal file
81
ProjectKiln/app/css/viewer/profile.css
Normal file
@@ -0,0 +1,81 @@
|
||||
.profile-view {
|
||||
max-width: 860px;
|
||||
}
|
||||
|
||||
.profile-header {
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.profile-header h1 {
|
||||
margin: 0;
|
||||
color: var(--bs-body-color);
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.profile-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 240px minmax(0, 1fr);
|
||||
gap: 32px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.profile-avatar-panel,
|
||||
.profile-form-panel {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.profile-avatar-preview {
|
||||
display: grid;
|
||||
width: 132px;
|
||||
height: 132px;
|
||||
margin-bottom: 14px;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 999px;
|
||||
background: var(--bs-secondary-bg);
|
||||
}
|
||||
|
||||
.profile-avatar-preview img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.profile-avatar-fallback {
|
||||
color: var(--bs-secondary-color);
|
||||
font-size: 42px;
|
||||
}
|
||||
|
||||
.profile-picture-button {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.profile-remove-picture {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--bs-secondary-color);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.profile-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.profile-status {
|
||||
color: var(--bs-secondary-color);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.profile-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
3
ProjectKiln/app/css/viewer/project.css
Normal file
3
ProjectKiln/app/css/viewer/project.css
Normal file
@@ -0,0 +1,3 @@
|
||||
.project-view {
|
||||
max-width: 1120px;
|
||||
}
|
||||
150
ProjectKiln/app/css/viewer/shared.css
Normal file
150
ProjectKiln/app/css/viewer/shared.css
Normal file
@@ -0,0 +1,150 @@
|
||||
.viewer-key {
|
||||
margin-bottom: 4px;
|
||||
color: var(--bs-secondary-color);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin: 0 0 14px;
|
||||
color: var(--bs-body-color);
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.viewer-description {
|
||||
min-height: 72px;
|
||||
color: var(--bs-body-color);
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.version-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.version-title {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin: -4px -6px 0;
|
||||
padding: 4px 6px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--bs-body-color);
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.version-detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 340px;
|
||||
gap: 32px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.version-panel {
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.version-inline-field {
|
||||
min-height: 34px;
|
||||
padding: 5px 7px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.version-title:hover,
|
||||
.version-title:focus,
|
||||
.version-inline-field:hover,
|
||||
.version-inline-field:focus {
|
||||
border-color: var(--bs-border-color);
|
||||
background: var(--bs-secondary-bg);
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.version-title.is-readonly:hover,
|
||||
.version-title.is-readonly:focus,
|
||||
.version-inline-field.is-readonly:hover,
|
||||
.version-inline-field.is-readonly:focus {
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.version-inline-field.is-empty {
|
||||
color: var(--bs-secondary-color);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.version-inline-field.is-saving {
|
||||
opacity: 0.65;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.version-inline-form {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.version-inline-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.version-inline-actions .btn {
|
||||
display: inline-grid;
|
||||
width: 36px;
|
||||
height: 32px;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.version-inline-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.meta-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.meta-row {
|
||||
display: grid;
|
||||
grid-template-columns: 120px minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.meta-label {
|
||||
color: var(--bs-secondary-color);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.meta-value {
|
||||
min-width: 0;
|
||||
color: var(--bs-body-color);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.version-header {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.version-detail-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.meta-row {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 3px;
|
||||
}
|
||||
}
|
||||
168
ProjectKiln/app/css/viewer/task_list.css
Normal file
168
ProjectKiln/app/css/viewer/task_list.css
Normal file
@@ -0,0 +1,168 @@
|
||||
.task-list-view {
|
||||
max-width: 1120px;
|
||||
}
|
||||
|
||||
.task-list-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.task-list-header h1 {
|
||||
margin: 0;
|
||||
color: var(--bs-body-color);
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.version-task-table {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.version-task-row {
|
||||
display: grid;
|
||||
grid-template-columns:
|
||||
minmax(92px, 0.7fr)
|
||||
minmax(180px, 1.5fr)
|
||||
minmax(120px, 0.9fr)
|
||||
minmax(130px, 0.9fr)
|
||||
minmax(150px, 1.1fr)
|
||||
minmax(120px, 0.85fr);
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.version-task-head {
|
||||
border-bottom: 1px solid var(--bs-border-color);
|
||||
background: var(--bs-secondary-bg);
|
||||
}
|
||||
|
||||
.version-task-head button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 7px;
|
||||
min-height: 42px;
|
||||
padding: 0 14px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--bs-secondary-color);
|
||||
text-align: left;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.version-task-head button:hover,
|
||||
.version-task-head button:focus,
|
||||
.version-task-head button.is-active {
|
||||
color: var(--bs-body-color);
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.version-task-head button i {
|
||||
width: 12px;
|
||||
color: inherit;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.version-task-item {
|
||||
min-height: 50px;
|
||||
padding: 8px 14px;
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--bs-border-color);
|
||||
background: var(--bs-body-bg);
|
||||
color: var(--bs-body-color);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.version-task-item:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.version-task-item:hover,
|
||||
.version-task-item:focus {
|
||||
background: var(--bs-secondary-bg);
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.version-task-id {
|
||||
color: var(--bs-secondary-color);
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.version-task-title {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.version-task-loading {
|
||||
padding: 14px;
|
||||
color: var(--bs-secondary-color);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.version-task-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
min-height: 26px;
|
||||
padding: 3px 8px;
|
||||
border: 1px solid color-mix(in srgb, var(--task-status-color, var(--color-accent)) 55%, var(--bs-border-color));
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--task-status-color, var(--color-accent)) 16%, transparent);
|
||||
color: var(--bs-body-color);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.version-task-pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
min-height: 36px;
|
||||
margin-top: 10px;
|
||||
color: var(--bs-secondary-color);
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.version-task-page-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.version-task-page-actions .btn {
|
||||
display: inline-grid;
|
||||
width: 32px;
|
||||
height: 30px;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.task-list-header {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.version-task-table {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.version-task-row {
|
||||
grid-template-columns: 100px 190px 130px 140px 170px 130px;
|
||||
min-width: 870px;
|
||||
}
|
||||
}
|
||||
7
ProjectKiln/app/css/viewer/version.css
Normal file
7
ProjectKiln/app/css/viewer/version.css
Normal file
@@ -0,0 +1,7 @@
|
||||
.version-view {
|
||||
max-width: 1120px;
|
||||
}
|
||||
|
||||
.version-task-section {
|
||||
max-width: 1080px;
|
||||
}
|
||||
@@ -1,12 +1,27 @@
|
||||
<?php
|
||||
|
||||
$pageStyles[] = 'app/css/home.css';
|
||||
$pageStyles[] = 'app/css/viewer.css';
|
||||
$pageStyles[] = 'app/css/viewer/shared.css';
|
||||
$pageStyles[] = 'app/css/viewer/dashboard.css';
|
||||
$pageStyles[] = 'app/css/viewer/project.css';
|
||||
$pageStyles[] = 'app/css/viewer/task_list.css';
|
||||
$pageStyles[] = 'app/css/viewer/task.css';
|
||||
$pageStyles[] = 'app/css/viewer/version.css';
|
||||
$pageStyles[] = 'app/css/viewer/profile.css';
|
||||
$pageStyles[] = 'app/css/viewer/admin.css';
|
||||
$pageStyles[] = 'app/css/popups.css';
|
||||
|
||||
$pageScripts[] = 'https://unpkg.com/cytoscape@3.34.0/dist/cytoscape.min.js';
|
||||
$pageScripts[] = 'app/js/home.js';
|
||||
$pageScripts[] = 'app/js/home/core.js';
|
||||
$pageScripts[] = 'app/js/home/dashboard.js';
|
||||
$pageScripts[] = 'app/js/home/project.js';
|
||||
$pageScripts[] = 'app/js/home/task_list.js';
|
||||
$pageScripts[] = 'app/js/home/task.js';
|
||||
$pageScripts[] = 'app/js/home/version.js';
|
||||
$pageScripts[] = 'app/js/home/profile.js';
|
||||
$pageScripts[] = 'app/js/home/admin.js';
|
||||
$pageScripts[] = 'app/js/home/router.js';
|
||||
$pageScripts[] = 'app/js/home/events.js';
|
||||
$pageScripts[] = 'app/js/popups.js';
|
||||
|
||||
$user = requireLogin();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
745
ProjectKiln/app/js/home/admin.js
Normal file
745
ProjectKiln/app/js/home/admin.js
Normal file
@@ -0,0 +1,745 @@
|
||||
async function loadWorkflowEditor() {
|
||||
const result = await apiGet('/api/workflow.php', {
|
||||
api: 'WorkflowData',
|
||||
_: Date.now()
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
document.getElementById('workflowGraph').innerHTML = '<div class="alert alert-danger m-3">Could not load workflow data.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
workflowData = result.workflow;
|
||||
if (!selectedWorkflowTaskType && workflowData.task_types.length) {
|
||||
selectedWorkflowTaskType = String(workflowData.task_types[0].id);
|
||||
}
|
||||
|
||||
if (selectedWorkflowTaskType && !workflowData.task_types.some((type) => String(type.id) === String(selectedWorkflowTaskType))) {
|
||||
selectedWorkflowTaskType = workflowData.task_types.length ? String(workflowData.task_types[0].id) : null;
|
||||
}
|
||||
|
||||
renderWorkflowEditor();
|
||||
}
|
||||
|
||||
function renderWorkflowEditor() {
|
||||
if (!workflowData) return;
|
||||
|
||||
populateWorkflowSelects();
|
||||
renderWorkflowLists();
|
||||
renderWorkflowGraph();
|
||||
}
|
||||
|
||||
function populateWorkflowSelects() {
|
||||
const stateOptions = workflowData.states.map((state) => ({
|
||||
id: state.id,
|
||||
name: state.name
|
||||
}));
|
||||
|
||||
populateSelect(document.getElementById('workflowTransitionFrom'), stateOptions, {
|
||||
placeholder: 'From state'
|
||||
});
|
||||
populateSelect(document.getElementById('workflowTransitionTo'), stateOptions, {
|
||||
placeholder: 'To state'
|
||||
});
|
||||
populateSelect(document.getElementById('workflowAssignmentState'), stateOptions, {
|
||||
placeholder: 'State'
|
||||
});
|
||||
populateSelect(document.getElementById('workflowTaskTypeSelect'), workflowData.task_types, {
|
||||
placeholder: 'Task type',
|
||||
selectedValue: selectedWorkflowTaskType ?? ''
|
||||
});
|
||||
|
||||
const assignedStates = getSelectedWorkflowAssignments()
|
||||
.map((assignment) => workflowData.states.find((state) => Number(state.id) === Number(assignment.state)))
|
||||
.filter(Boolean);
|
||||
const selectedType = getSelectedWorkflowTaskType();
|
||||
|
||||
populateSelect(document.getElementById('workflowDefaultStateSelect'), assignedStates, {
|
||||
placeholder: 'No default state',
|
||||
selectedValue: selectedType?.default_state ?? ''
|
||||
});
|
||||
}
|
||||
|
||||
function renderWorkflowLists() {
|
||||
const statesById = new Map(workflowData.states.map((state) => [Number(state.id), state]));
|
||||
const selectedAssignments = getSelectedWorkflowAssignments();
|
||||
|
||||
const stateList = document.getElementById('workflowStateList');
|
||||
stateList.innerHTML = workflowData.states.map((state) => `
|
||||
<div class="workflow-list-item">
|
||||
<div class="workflow-list-main">
|
||||
<span class="workflow-color-dot" style="background: ${escapeHtml(state.color)}"></span>
|
||||
<span>${escapeHtml(state.name)}</span>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-outline-danger" type="button" data-workflow-delete-state="${escapeHtml(state.id)}">
|
||||
<i class="fa-solid fa-trash-can"></i>
|
||||
</button>
|
||||
</div>
|
||||
`).join('') || '<div class="text-secondary">No states yet.</div>';
|
||||
|
||||
const transitionList = document.getElementById('workflowTransitionList');
|
||||
transitionList.innerHTML = workflowData.transitions.map((transition) => `
|
||||
<div class="workflow-list-item">
|
||||
<div class="workflow-list-main">
|
||||
<span>${escapeHtml(statesById.get(Number(transition.from_id))?.name ?? transition.from_id)}</span>
|
||||
<i class="fa-solid fa-arrow-right"></i>
|
||||
<span>${escapeHtml(statesById.get(Number(transition.to_id))?.name ?? transition.to_id)}</span>
|
||||
<span class="text-secondary">${escapeHtml(transition.action_name)}</span>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-outline-danger" type="button" data-workflow-delete-transition="${escapeHtml(transition.id)}">
|
||||
<i class="fa-solid fa-trash-can"></i>
|
||||
</button>
|
||||
</div>
|
||||
`).join('') || '<div class="text-secondary">No transitions yet.</div>';
|
||||
|
||||
const assignmentList = document.getElementById('workflowAssignmentList');
|
||||
assignmentList.innerHTML = selectedAssignments.map((assignment) => `
|
||||
<div class="workflow-list-item">
|
||||
<div class="workflow-list-main">
|
||||
<span>${escapeHtml(statesById.get(Number(assignment.state))?.name ?? assignment.state)}</span>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-outline-danger" type="button" data-workflow-remove-assignment="${escapeHtml(assignment.id)}">
|
||||
<i class="fa-solid fa-trash-can"></i>
|
||||
</button>
|
||||
</div>
|
||||
`).join('') || '<div class="text-secondary">No assigned states yet.</div>';
|
||||
}
|
||||
|
||||
function getSelectedWorkflowTaskType() {
|
||||
return workflowData?.task_types.find((type) => String(type.id) === String(selectedWorkflowTaskType)) ?? null;
|
||||
}
|
||||
|
||||
function getSelectedWorkflowAssignments() {
|
||||
if (!selectedWorkflowTaskType) return [];
|
||||
|
||||
return workflowData.assignments.filter((assignment) => String(assignment.task_type) === String(selectedWorkflowTaskType));
|
||||
}
|
||||
|
||||
function renderWorkflowGraph() {
|
||||
const graph = document.getElementById('workflowGraph');
|
||||
const empty = document.getElementById('workflowGraphEmpty');
|
||||
|
||||
if (!graph || !empty) return;
|
||||
|
||||
const assignedStateIds = new Set(getSelectedWorkflowAssignments().map((assignment) => Number(assignment.state)));
|
||||
const visibleStates = workflowData.states.filter((state) => assignedStateIds.has(Number(state.id)));
|
||||
const visibleTransitions = workflowData.transitions.filter((transition) => (
|
||||
assignedStateIds.has(Number(transition.from_id))
|
||||
&& assignedStateIds.has(Number(transition.to_id))
|
||||
));
|
||||
|
||||
if (!visibleStates.length) {
|
||||
if (workflowGraph) {
|
||||
workflowGraph.destroy();
|
||||
workflowGraph = null;
|
||||
}
|
||||
|
||||
graph.innerHTML = '';
|
||||
empty.hidden = false;
|
||||
empty.textContent = selectedWorkflowTaskType
|
||||
? 'Assign states to this task type to render its workflow graph.'
|
||||
: 'Select a task type to render its workflow graph.';
|
||||
return;
|
||||
}
|
||||
|
||||
empty.hidden = true;
|
||||
|
||||
if (typeof cytoscape !== 'function') {
|
||||
graph.innerHTML = '<div class="alert alert-warning m-3">Cytoscape.js could not be loaded.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const elements = [
|
||||
...visibleStates.map((state) => ({
|
||||
data: {
|
||||
id: `state-${state.id}`,
|
||||
label: state.name,
|
||||
color: state.color
|
||||
}
|
||||
})),
|
||||
...visibleTransitions.map((transition) => ({
|
||||
data: {
|
||||
id: `transition-${transition.id}`,
|
||||
source: `state-${transition.from_id}`,
|
||||
target: `state-${transition.to_id}`,
|
||||
label: transition.action_name
|
||||
}
|
||||
}))
|
||||
];
|
||||
const styles = getComputedStyle(document.documentElement);
|
||||
const borderColor = styles.getPropertyValue('--bs-border-color').trim() || '#3a3f46';
|
||||
const bodyColor = styles.getPropertyValue('--bs-body-color').trim() || '#f3f4f6';
|
||||
const secondaryColor = styles.getPropertyValue('--bs-secondary-color').trim() || '#aeb7c4';
|
||||
|
||||
if (workflowGraph) {
|
||||
workflowGraph.destroy();
|
||||
}
|
||||
|
||||
workflowGraph = cytoscape({
|
||||
container: graph,
|
||||
elements,
|
||||
style: [
|
||||
{
|
||||
selector: 'node',
|
||||
style: {
|
||||
'background-color': 'data(color)',
|
||||
'border-color': borderColor,
|
||||
'border-width': 1,
|
||||
'color': bodyColor,
|
||||
'font-size': 13,
|
||||
'font-weight': 700,
|
||||
'label': 'data(label)',
|
||||
'text-valign': 'center',
|
||||
'text-halign': 'center',
|
||||
'text-wrap': 'wrap',
|
||||
'text-max-width': 90,
|
||||
'width': 86,
|
||||
'height': 42,
|
||||
'shape': 'round-rectangle'
|
||||
}
|
||||
},
|
||||
{
|
||||
selector: 'edge',
|
||||
style: {
|
||||
'curve-style': 'bezier',
|
||||
'target-arrow-shape': 'triangle',
|
||||
'line-color': secondaryColor,
|
||||
'target-arrow-color': secondaryColor,
|
||||
'color': secondaryColor,
|
||||
'font-size': 11,
|
||||
'label': 'data(label)',
|
||||
'text-rotation': 'autorotate',
|
||||
'text-margin-y': -8,
|
||||
'width': 2
|
||||
}
|
||||
}
|
||||
],
|
||||
layout: {
|
||||
name: 'breadthfirst',
|
||||
directed: true,
|
||||
padding: 36,
|
||||
spacingFactor: 1.25
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function saveWorkflowAction(api, body = {}, params = {}) {
|
||||
const result = await apiPost('/api/workflow.php', {
|
||||
api,
|
||||
...params
|
||||
}, body);
|
||||
|
||||
if (!result.success) {
|
||||
alert(result.error || 'Could not save workflow change.');
|
||||
return false;
|
||||
}
|
||||
|
||||
await loadWorkflowEditor();
|
||||
return true;
|
||||
}
|
||||
|
||||
async function loadAdminOptions() {
|
||||
let result = null;
|
||||
|
||||
try {
|
||||
result = await apiGet('/api/admin_options.php', {
|
||||
api: 'OptionsData',
|
||||
_: Date.now()
|
||||
});
|
||||
} catch (error) {
|
||||
result = {
|
||||
success: false,
|
||||
error: 'Could not load admin options.'
|
||||
};
|
||||
}
|
||||
|
||||
if (!result.success) {
|
||||
const typeEditor = document.getElementById('adminSelectedTypeEditor');
|
||||
const priorityList = document.getElementById('adminPriorityList');
|
||||
const userRightsList = document.getElementById('adminUserRightsList');
|
||||
|
||||
if (typeEditor) {
|
||||
typeEditor.innerHTML = '<div class="text-danger">Could not load task types.</div>';
|
||||
}
|
||||
|
||||
if (priorityList) {
|
||||
priorityList.innerHTML = '<div class="text-danger">Could not load priorities.</div>';
|
||||
}
|
||||
|
||||
if (userRightsList) {
|
||||
userRightsList.innerHTML = '<div class="text-danger">Could not load users.</div>';
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
adminOptionsData = {
|
||||
types: result.options?.types ?? [],
|
||||
priorities: result.options?.priorities ?? [],
|
||||
custom_fields: result.options?.custom_fields ?? [],
|
||||
users: result.options?.users ?? [],
|
||||
rights: result.options?.rights ?? [],
|
||||
user_rights: result.options?.user_rights ?? [],
|
||||
projects: result.options?.projects ?? [],
|
||||
user_access: result.options?.user_access ?? []
|
||||
};
|
||||
if (!selectedAdminTaskType && adminOptionsData.types.length) {
|
||||
selectedAdminTaskType = String(adminOptionsData.types[0].id);
|
||||
}
|
||||
|
||||
if (selectedAdminTaskType && !adminOptionsData.types.some((type) => String(type.id) === String(selectedAdminTaskType))) {
|
||||
selectedAdminTaskType = adminOptionsData.types.length ? String(adminOptionsData.types[0].id) : null;
|
||||
}
|
||||
|
||||
if (!selectedAdminUser && adminOptionsData.users.length) {
|
||||
selectedAdminUser = String(adminOptionsData.users[0].id);
|
||||
}
|
||||
|
||||
if (selectedAdminUser && !adminOptionsData.users.some((user) => String(user.id) === String(selectedAdminUser))) {
|
||||
selectedAdminUser = adminOptionsData.users.length ? String(adminOptionsData.users[0].id) : null;
|
||||
}
|
||||
|
||||
renderAdminOptions();
|
||||
}
|
||||
|
||||
function renderAdminOptions() {
|
||||
if (!adminOptionsData) return;
|
||||
|
||||
const types = adminOptionsData.types ?? [];
|
||||
const priorities = adminOptionsData.priorities ?? [];
|
||||
|
||||
populateSelect(document.getElementById('adminTaskTypeSelect'), types, {
|
||||
placeholder: 'Select task type',
|
||||
selectedValue: selectedAdminTaskType ?? ''
|
||||
});
|
||||
renderSelectedAdminTaskType();
|
||||
renderAdminCustomFields();
|
||||
renderAdminOptionList('priority', priorities, document.getElementById('adminPriorityList'));
|
||||
renderAdminUsers();
|
||||
}
|
||||
|
||||
function getSelectedAdminTaskType() {
|
||||
return adminOptionsData?.types.find((type) => String(type.id) === String(selectedAdminTaskType)) ?? null;
|
||||
}
|
||||
|
||||
function renderSelectedAdminTaskType() {
|
||||
const container = document.getElementById('adminSelectedTypeEditor');
|
||||
|
||||
if (!container) return;
|
||||
|
||||
const type = getSelectedAdminTaskType();
|
||||
|
||||
if (!type) {
|
||||
container.innerHTML = '<div class="text-secondary">Create or select a task type first.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = `
|
||||
<form class="admin-option-item admin-selected-option" data-admin-option-edit="type" data-option-id="${escapeHtml(type.id)}">
|
||||
<div class="admin-option-icon">
|
||||
${type.icon
|
||||
? `<img src="${escapeHtml(type.icon)}" alt="">`
|
||||
: '<i class="fa-solid fa-tag"></i>'}
|
||||
</div>
|
||||
<input class="form-control form-control-sm" name="name" value="${escapeHtml(type.name)}" required maxlength="20">
|
||||
<input class="form-control form-control-sm" type="file" name="logo" accept=".svg,image/svg+xml" aria-label="Logo">
|
||||
<label class="admin-option-remove-logo">
|
||||
<input type="checkbox" name="remove_logo" value="1">
|
||||
<span>Remove logo</span>
|
||||
</label>
|
||||
<div class="admin-option-actions">
|
||||
<button class="btn btn-sm btn-primary" type="submit" title="Save">
|
||||
<i class="fa-solid fa-check"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-danger" type="button" data-admin-option-delete="type" data-option-id="${escapeHtml(type.id)}" title="Delete">
|
||||
<i class="fa-solid fa-trash-can"></i>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderAdminCustomFields() {
|
||||
const container = document.getElementById('adminCustomFieldList');
|
||||
|
||||
if (!container) return;
|
||||
|
||||
if (!selectedAdminTaskType) {
|
||||
container.innerHTML = '<div class="text-secondary">Select a task type first.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const fields = (adminOptionsData.custom_fields ?? [])
|
||||
.filter((field) => String(field.task_type) === String(selectedAdminTaskType));
|
||||
|
||||
container.innerHTML = fields.map((field) => `
|
||||
<form class="admin-custom-field-item" data-admin-custom-field-edit="${escapeHtml(field.id)}">
|
||||
<input class="form-control form-control-sm" name="name" value="${escapeHtml(field.name)}" required maxlength="128">
|
||||
<select class="form-select form-select-sm" name="type" required>
|
||||
${customFieldTypes.map((type) => `
|
||||
<option value="${escapeHtml(type.id)}" ${String(type.id) === String(field.type) ? 'selected' : ''}>${escapeHtml(type.name)}</option>
|
||||
`).join('')}
|
||||
</select>
|
||||
<input class="form-control form-control-sm" name="value" value="${escapeHtml(field.raw_value ?? '')}">
|
||||
<div class="admin-option-actions">
|
||||
<button class="btn btn-sm btn-primary" type="submit" title="Save">
|
||||
<i class="fa-solid fa-check"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-danger" type="button" data-admin-custom-field-delete="${escapeHtml(field.id)}" title="Delete">
|
||||
<i class="fa-solid fa-trash-can"></i>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
`).join('') || '<div class="text-secondary">No custom fields yet.</div>';
|
||||
}
|
||||
|
||||
function renderAdminOptionList(kind, options, container) {
|
||||
if (!container) return;
|
||||
|
||||
container.innerHTML = options.map((option) => `
|
||||
<form class="admin-option-item" data-admin-option-edit="${escapeHtml(kind)}" data-option-id="${escapeHtml(option.id)}">
|
||||
<div class="admin-option-icon">
|
||||
${option.icon
|
||||
? `<img src="${escapeHtml(option.icon)}" alt="">`
|
||||
: '<i class="fa-solid fa-tag"></i>'}
|
||||
</div>
|
||||
<input class="form-control form-control-sm" name="name" value="${escapeHtml(option.name)}" required maxlength="128">
|
||||
<input class="form-control form-control-sm" type="file" name="logo" accept=".svg,image/svg+xml" aria-label="Logo">
|
||||
<label class="admin-option-remove-logo">
|
||||
<input type="checkbox" name="remove_logo" value="1">
|
||||
<span>Remove logo</span>
|
||||
</label>
|
||||
<div class="admin-option-actions">
|
||||
<button class="btn btn-sm btn-primary" type="submit" title="Save">
|
||||
<i class="fa-solid fa-check"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-danger" type="button" data-admin-option-delete="${escapeHtml(kind)}" data-option-id="${escapeHtml(option.id)}" title="Delete">
|
||||
<i class="fa-solid fa-trash-can"></i>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
`).join('') || '<div class="text-secondary">No entries yet.</div>';
|
||||
}
|
||||
|
||||
function getSelectedAdminUser() {
|
||||
return adminOptionsData?.users.find((user) => String(user.id) === String(selectedAdminUser)) ?? null;
|
||||
}
|
||||
|
||||
function userHasAdminRight(userId, rightId) {
|
||||
return (adminOptionsData?.user_rights ?? []).some((entry) => (
|
||||
String(entry.user_id) === String(userId)
|
||||
&& String(entry.right_id) === String(rightId)
|
||||
));
|
||||
}
|
||||
|
||||
function getAdminUserAccess(userId) {
|
||||
return (adminOptionsData?.user_access ?? []).filter((entry) => (
|
||||
String(entry.user_id) === String(userId)
|
||||
));
|
||||
}
|
||||
|
||||
function getAdminProject(projectId) {
|
||||
return (adminOptionsData?.projects ?? []).find((project) => (
|
||||
String(project.id) === String(projectId)
|
||||
)) ?? null;
|
||||
}
|
||||
|
||||
function renderAdminUsers() {
|
||||
const select = document.getElementById('adminUserSelect');
|
||||
const container = document.getElementById('adminUserRightsList');
|
||||
|
||||
if (!select || !container) return;
|
||||
|
||||
const users = adminOptionsData.users ?? [];
|
||||
const rights = adminOptionsData.rights ?? [];
|
||||
const projects = adminOptionsData.projects ?? [];
|
||||
const selectedUser = getSelectedAdminUser();
|
||||
|
||||
populateSelect(select, users, {
|
||||
placeholder: 'Select user',
|
||||
selectedValue: selectedAdminUser ?? ''
|
||||
});
|
||||
|
||||
if (!selectedUser) {
|
||||
container.innerHTML = '<div class="text-secondary">Select a user first.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!rights.length) {
|
||||
container.innerHTML = '<div class="text-secondary">No rights configured yet.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const explicitAccess = getAdminUserAccess(selectedUser.id);
|
||||
const explicitProjectIds = new Set(explicitAccess.map((entry) => String(entry.project_id)));
|
||||
const ownerProjects = projects.filter((project) => String(project.owner) === String(selectedUser.id));
|
||||
const ownerProjectIds = new Set(ownerProjects.map((project) => String(project.id)));
|
||||
const addableProjects = projects.filter((project) => (
|
||||
!explicitProjectIds.has(String(project.id))
|
||||
&& !ownerProjectIds.has(String(project.id))
|
||||
));
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="admin-user-summary">
|
||||
${renderUser(selectedUser)}
|
||||
</div>
|
||||
|
||||
<div class="admin-user-section">
|
||||
<h4>Rights</h4>
|
||||
<div class="admin-right-list">
|
||||
${rights.map((right) => {
|
||||
const checked = userHasAdminRight(selectedUser.id, right.id);
|
||||
const isSelfAdminRight = appFlag('isAdmin')
|
||||
&& String(selectedUser.id) === String(getCurrentUserId())
|
||||
&& String(right.name) === 'Admin';
|
||||
|
||||
return `
|
||||
<label class="admin-right-item">
|
||||
<input
|
||||
type="checkbox"
|
||||
data-admin-user-right
|
||||
data-user-id="${escapeHtml(selectedUser.id)}"
|
||||
data-right-id="${escapeHtml(right.id)}"
|
||||
${checked ? 'checked' : ''}
|
||||
${isSelfAdminRight ? 'disabled' : ''}
|
||||
>
|
||||
<span>${escapeHtml(right.name)}</span>
|
||||
</label>
|
||||
`;
|
||||
}).join('')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-user-section">
|
||||
<h4>Project Access</h4>
|
||||
<form class="admin-project-access-form" id="adminProjectAccessForm">
|
||||
<select class="form-select form-select-sm" name="project_id" ${addableProjects.length ? '' : 'disabled'}>
|
||||
${addableProjects.map((project) => `
|
||||
<option value="${escapeHtml(project.id)}">${escapeHtml(project.name)} (${escapeHtml(project.id)})</option>
|
||||
`).join('')}
|
||||
</select>
|
||||
<button class="btn btn-sm btn-primary" type="submit" ${addableProjects.length ? '' : 'disabled'}>
|
||||
<i class="fa-solid fa-plus me-1"></i>
|
||||
Add Access
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="admin-project-access-list">
|
||||
${explicitAccess.map((access) => {
|
||||
const project = getAdminProject(access.project_id);
|
||||
|
||||
return `
|
||||
<div class="admin-project-access-item">
|
||||
<span>${escapeHtml(project?.name ?? access.project_id)} <small>${escapeHtml(access.project_id)}</small></span>
|
||||
<button class="btn btn-sm btn-outline-danger" type="button" data-admin-project-access-delete="${escapeHtml(access.id)}" title="Remove access">
|
||||
<i class="fa-solid fa-trash-can"></i>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}).join('') || '<div class="text-secondary">No explicit project access yet.</div>'}
|
||||
</div>
|
||||
|
||||
${ownerProjects.length ? `
|
||||
<div class="admin-project-owner-list">
|
||||
<strong>Owner access</strong>
|
||||
${ownerProjects.map((project) => `
|
||||
<span>${escapeHtml(project.name)} <small>${escapeHtml(project.id)}</small></span>
|
||||
`).join('')}
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async function setAdminUserRight(userId, rightId, enabled) {
|
||||
const result = await apiPost('/api/admin_options.php', {
|
||||
api: 'SetUserRight'
|
||||
}, {
|
||||
user_id: userId,
|
||||
right_id: rightId,
|
||||
enabled: enabled ? 1 : 0
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
alert(result.error || 'Could not update user right.');
|
||||
return false;
|
||||
}
|
||||
|
||||
await loadAdminOptions();
|
||||
return true;
|
||||
}
|
||||
|
||||
async function grantAdminProjectAccess(userId, projectId) {
|
||||
const result = await apiPost('/api/admin_options.php', {
|
||||
api: 'GrantProjectAccess'
|
||||
}, {
|
||||
user_id: userId,
|
||||
project_id: projectId
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
alert(result.error || 'Could not add project access.');
|
||||
return false;
|
||||
}
|
||||
|
||||
await loadAdminOptions();
|
||||
return true;
|
||||
}
|
||||
|
||||
async function revokeAdminProjectAccess(accessId) {
|
||||
const result = await apiPost('/api/admin_options.php', {
|
||||
api: 'RevokeProjectAccess',
|
||||
access_id: accessId
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
alert(result.error || 'Could not remove project access.');
|
||||
return false;
|
||||
}
|
||||
|
||||
await loadAdminOptions();
|
||||
return true;
|
||||
}
|
||||
|
||||
async function saveAdminOptionForm(kind, form, optionId = null) {
|
||||
const formData = new FormData(form);
|
||||
formData.set('kind', kind);
|
||||
|
||||
if (optionId) {
|
||||
formData.set('id', optionId);
|
||||
}
|
||||
|
||||
const result = await apiPostForm('/api/admin_options.php', {
|
||||
api: optionId ? 'UpdateOption' : 'CreateOption'
|
||||
}, formData);
|
||||
|
||||
if (!result.success) {
|
||||
alert(result.error || 'Could not save option.');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (kind === 'type' && !optionId && result.option_id) {
|
||||
selectedAdminTaskType = String(result.option_id);
|
||||
}
|
||||
|
||||
taskLookupCache.types = null;
|
||||
taskLookupCache.priorities = null;
|
||||
await populateTaskOptionSelects();
|
||||
await loadAdminOptions();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function deleteAdminOption(kind, optionId) {
|
||||
const result = await apiPost('/api/admin_options.php', {
|
||||
api: 'DeleteOption',
|
||||
kind,
|
||||
id: optionId
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
alert(result.error || 'Could not delete option.');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (kind === 'type' && String(selectedAdminTaskType) === String(optionId)) {
|
||||
selectedAdminTaskType = null;
|
||||
}
|
||||
|
||||
taskLookupCache.types = null;
|
||||
taskLookupCache.priorities = null;
|
||||
await populateTaskOptionSelects();
|
||||
await loadAdminOptions();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function saveAdminCustomFieldForm(form, fieldId = null) {
|
||||
if (!selectedAdminTaskType) {
|
||||
alert('Select a task type first.');
|
||||
return false;
|
||||
}
|
||||
|
||||
const data = Object.fromEntries(new FormData(form));
|
||||
data.task_type = selectedAdminTaskType;
|
||||
|
||||
if (fieldId) {
|
||||
data.id = fieldId;
|
||||
}
|
||||
|
||||
const result = await apiPost('/api/admin_options.php', {
|
||||
api: fieldId ? 'UpdateCustomField' : 'CreateCustomField'
|
||||
}, data);
|
||||
|
||||
if (!result.success) {
|
||||
alert(result.error || 'Could not save custom field.');
|
||||
return false;
|
||||
}
|
||||
|
||||
await loadAdminOptions();
|
||||
return true;
|
||||
}
|
||||
|
||||
async function deleteAdminCustomField(fieldId) {
|
||||
const result = await apiPost('/api/admin_options.php', {
|
||||
api: 'DeleteCustomField',
|
||||
field_id: fieldId
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
alert(result.error || 'Could not delete custom field.');
|
||||
return false;
|
||||
}
|
||||
|
||||
await loadAdminOptions();
|
||||
return true;
|
||||
}
|
||||
|
||||
function loadAdmin(section = 'workflows', pushHistory = true) {
|
||||
if (document.querySelector('.kiln-app')?.dataset.isAdmin !== '1') {
|
||||
loadDashboard(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const view = showView('adminView');
|
||||
|
||||
if (!view) return;
|
||||
|
||||
if (!['workflows', 'options', 'users'].includes(section)) {
|
||||
section = 'workflows';
|
||||
}
|
||||
|
||||
document.querySelectorAll('[data-admin-section]').forEach((button) => {
|
||||
button.classList.toggle('is-active', button.dataset.adminSection === section);
|
||||
});
|
||||
|
||||
document.querySelectorAll('.admin-panel').forEach((panel) => {
|
||||
panel.hidden = true;
|
||||
});
|
||||
|
||||
const panelBySection = {
|
||||
workflows: 'adminWorkflowsPanel',
|
||||
options: 'adminOptionsPanel',
|
||||
users: 'adminUsersPanel'
|
||||
};
|
||||
const panel = document.getElementById(panelBySection[section]);
|
||||
if (panel) panel.hidden = false;
|
||||
|
||||
if (pushHistory) {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('page', 'home');
|
||||
url.searchParams.set('admin', section);
|
||||
url.searchParams.delete('task');
|
||||
url.searchParams.delete('project');
|
||||
url.searchParams.delete('tasks');
|
||||
url.searchParams.delete('version');
|
||||
url.searchParams.delete('profile');
|
||||
window.history.pushState({}, '', url);
|
||||
}
|
||||
|
||||
if (section === 'workflows') {
|
||||
loadWorkflowEditor();
|
||||
} else if (section === 'options' || section === 'users') {
|
||||
loadAdminOptions();
|
||||
}
|
||||
}
|
||||
524
ProjectKiln/app/js/home/core.js
Normal file
524
ProjectKiln/app/js/home/core.js
Normal file
@@ -0,0 +1,524 @@
|
||||
async function apiGet(path, params = {}) {
|
||||
const url = new URL(path, window.location.origin);
|
||||
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
url.searchParams.set(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
cache: 'no-store',
|
||||
headers: {
|
||||
'Cache-Control': 'no-cache'
|
||||
}
|
||||
});
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function apiPost(path, params = {}, body = {}) {
|
||||
const url = new URL(path, window.location.origin);
|
||||
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
url.searchParams.set(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
method: 'POST',
|
||||
cache: 'no-store',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-cache'
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch (error) {
|
||||
console.error('API did not return JSON:', text);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: 'API did not return JSON. Check console for PHP error.'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function apiPostForm(path, params = {}, formData) {
|
||||
const url = new URL(path, window.location.origin);
|
||||
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
url.searchParams.set(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
method: 'POST',
|
||||
cache: 'no-store',
|
||||
headers: {
|
||||
'Cache-Control': 'no-cache'
|
||||
},
|
||||
body: formData
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch (error) {
|
||||
console.error('API did not return JSON:', text);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: 'API did not return JSON. Check console for PHP error.'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let currentTask = null;
|
||||
let currentVersion = null;
|
||||
let currentProject = null;
|
||||
let currentProfileUser = null;
|
||||
let currentVersionTasks = [];
|
||||
let currentProjectTasks = [];
|
||||
let currentProjectTaskProject = null;
|
||||
let currentTaskComments = [];
|
||||
let workflowData = null;
|
||||
let workflowGraph = null;
|
||||
let selectedWorkflowTaskType = null;
|
||||
let adminOptionsData = null;
|
||||
let selectedAdminTaskType = null;
|
||||
let selectedAdminUser = null;
|
||||
let projectTreePromise = null;
|
||||
|
||||
const versionTaskSort = {
|
||||
field: 'id',
|
||||
direction: 'asc'
|
||||
};
|
||||
|
||||
const projectTaskSort = {
|
||||
field: 'id',
|
||||
direction: 'asc'
|
||||
};
|
||||
|
||||
const taskTablePageSize = 100;
|
||||
let versionTaskPagination = null;
|
||||
let projectTaskPagination = null;
|
||||
|
||||
const themeStyles = {
|
||||
white: 'app/css/white_mode.css',
|
||||
dark: 'app/css/dark_mode.css',
|
||||
purple: 'app/css/purple_mode.css',
|
||||
green: 'app/css/green_mode.css',
|
||||
beige: 'app/css/beige_mode.css'
|
||||
};
|
||||
|
||||
const customFieldTypes = [
|
||||
{ id: 'string', name: 'String' },
|
||||
{ id: 'int', name: 'Int' },
|
||||
{ id: 'float', name: 'Float' },
|
||||
{ id: 'date', name: 'Date' },
|
||||
{ id: 'boolean', name: 'Boolean' },
|
||||
{ id: 'text', name: 'Text' },
|
||||
{ id: 'json', name: 'JSON' }
|
||||
];
|
||||
|
||||
const taskLookupCache = {
|
||||
types: null,
|
||||
priorities: null,
|
||||
users: null,
|
||||
versionsByProject: new Map(),
|
||||
projectsById: new Map(),
|
||||
projectBlocksById: new Map()
|
||||
};
|
||||
|
||||
function appFlag(name) {
|
||||
return document.querySelector('.kiln-app')?.dataset[name] === '1';
|
||||
}
|
||||
|
||||
function canEditTasks() {
|
||||
return appFlag('canEditTasks');
|
||||
}
|
||||
|
||||
function canEditVersions() {
|
||||
return appFlag('canEditVersions');
|
||||
}
|
||||
|
||||
function canEditProjects() {
|
||||
return appFlag('canEditProjects');
|
||||
}
|
||||
|
||||
function applyPermissionVisibility() {
|
||||
const visibility = [
|
||||
['taskEditButton', canEditTasks()],
|
||||
['versionEditButton', canEditVersions()],
|
||||
['projectEditButton', canEditProjects()]
|
||||
];
|
||||
|
||||
visibility.forEach(([id, allowed]) => {
|
||||
const element = document.getElementById(id);
|
||||
if (element) element.hidden = !allowed;
|
||||
});
|
||||
|
||||
[
|
||||
['.task-editable', canEditTasks()],
|
||||
['.version-editable', canEditVersions()],
|
||||
['.project-editable', canEditProjects()]
|
||||
].forEach(([selector, allowed]) => {
|
||||
document.querySelectorAll(selector).forEach((element) => {
|
||||
element.classList.toggle('is-readonly', !allowed);
|
||||
|
||||
if (allowed) {
|
||||
element.setAttribute('tabindex', '0');
|
||||
element.removeAttribute('aria-disabled');
|
||||
return;
|
||||
}
|
||||
|
||||
element.removeAttribute('title');
|
||||
element.removeAttribute('tabindex');
|
||||
element.setAttribute('aria-disabled', 'true');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function showView(viewId) {
|
||||
document.querySelectorAll('#viewer > section').forEach((section) => {
|
||||
section.hidden = true;
|
||||
});
|
||||
|
||||
const view = document.getElementById(viewId);
|
||||
|
||||
if (!view) {
|
||||
console.error(`Missing view: ${viewId}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
view.hidden = false;
|
||||
return view;
|
||||
}
|
||||
|
||||
function setText(id, value) {
|
||||
const element = document.getElementById(id);
|
||||
|
||||
if (element) {
|
||||
element.textContent = value ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
function setEditableText(id, displayValue, rawValue = displayValue, emptyText = '-') {
|
||||
const element = document.getElementById(id);
|
||||
|
||||
if (!element) return;
|
||||
|
||||
const hasValue = displayValue !== null && displayValue !== undefined && displayValue !== '';
|
||||
element.textContent = hasValue ? displayValue : emptyText;
|
||||
element.dataset.currentValue = rawValue ?? '';
|
||||
element.classList.remove('is-saving');
|
||||
element.classList.toggle('is-empty', !hasValue);
|
||||
}
|
||||
|
||||
function setEditableHtml(id, displayHtml, rawValue, emptyText = '-') {
|
||||
const element = document.getElementById(id);
|
||||
|
||||
if (!element) return;
|
||||
|
||||
const hasValue = displayHtml !== null && displayHtml !== undefined && displayHtml !== '-';
|
||||
element.innerHTML = hasValue ? displayHtml : emptyText;
|
||||
element.dataset.currentValue = rawValue ?? '';
|
||||
element.classList.remove('is-saving');
|
||||
element.classList.toggle('is-empty', !hasValue || rawValue === '' || rawValue === null);
|
||||
}
|
||||
|
||||
function setVersionEditableText(id, displayValue, rawValue = displayValue, emptyText = '-') {
|
||||
const element = document.getElementById(id);
|
||||
|
||||
if (!element) return;
|
||||
|
||||
const hasValue = displayValue !== null && displayValue !== undefined && displayValue !== '';
|
||||
element.textContent = hasValue ? displayValue : emptyText;
|
||||
element.dataset.currentValue = rawValue ?? '';
|
||||
element.classList.remove('is-saving');
|
||||
element.classList.toggle('is-empty', !hasValue);
|
||||
}
|
||||
|
||||
function renderMetaBadge(item) {
|
||||
if (!item) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
const icon = item.icon
|
||||
? `<img src="${item.icon}" alt="" class="meta-icon">`
|
||||
: '';
|
||||
|
||||
return `
|
||||
<span class="meta-badge">
|
||||
${icon}
|
||||
<span>${escapeHtml(item.name)}</span>
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
|
||||
async function getTaskTypes() {
|
||||
if (taskLookupCache.types) {
|
||||
return taskLookupCache.types;
|
||||
}
|
||||
|
||||
const result = await apiGet('/api/task.php', {
|
||||
api: 'ListTypes'
|
||||
});
|
||||
|
||||
taskLookupCache.types = result.success ? result.types : [];
|
||||
return taskLookupCache.types;
|
||||
}
|
||||
|
||||
async function getTaskPriorities() {
|
||||
if (taskLookupCache.priorities) {
|
||||
return taskLookupCache.priorities;
|
||||
}
|
||||
|
||||
const result = await apiGet('/api/task.php', {
|
||||
api: 'ListPriorities'
|
||||
});
|
||||
|
||||
taskLookupCache.priorities = result.success ? result.priorities : [];
|
||||
return taskLookupCache.priorities;
|
||||
}
|
||||
|
||||
async function getUsers() {
|
||||
if (taskLookupCache.users) {
|
||||
return taskLookupCache.users;
|
||||
}
|
||||
|
||||
const result = await apiGet('/api/user.php', {
|
||||
api: 'ListUsers'
|
||||
});
|
||||
|
||||
taskLookupCache.users = result.success ? result.users : [];
|
||||
return taskLookupCache.users;
|
||||
}
|
||||
|
||||
async function getProjectVersions(projectId) {
|
||||
if (taskLookupCache.versionsByProject.has(projectId)) {
|
||||
return taskLookupCache.versionsByProject.get(projectId);
|
||||
}
|
||||
|
||||
const result = await apiGet('/api/version.php', {
|
||||
api: 'ListVersions',
|
||||
project_id: projectId
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
taskLookupCache.versionsByProject.set(projectId, []);
|
||||
return [];
|
||||
}
|
||||
|
||||
const versions = [];
|
||||
|
||||
for (const versionId of result.versions) {
|
||||
const versionInfo = await apiGet('/api/version.php', {
|
||||
api: 'VersionInfo',
|
||||
version_id: versionId
|
||||
});
|
||||
|
||||
if (versionInfo.success && versionInfo.version) {
|
||||
versions.push(versionInfo.version);
|
||||
}
|
||||
}
|
||||
|
||||
taskLookupCache.versionsByProject.set(projectId, versions);
|
||||
return versions;
|
||||
}
|
||||
|
||||
async function populateTaskOptionSelects() {
|
||||
const [types, priorities] = await Promise.all([
|
||||
getTaskTypes(),
|
||||
getTaskPriorities()
|
||||
]);
|
||||
|
||||
populateSelect(document.getElementById('createTaskType'), types, {
|
||||
placeholder: 'Select type'
|
||||
});
|
||||
|
||||
populateSelect(document.getElementById('createTaskPriority'), priorities, {
|
||||
placeholder: 'Select priority'
|
||||
});
|
||||
}
|
||||
|
||||
async function populateTaskProjectSelect(selectedProjectId = '') {
|
||||
const result = await apiGet('/api/project.php', {
|
||||
api: 'ListProjects'
|
||||
});
|
||||
|
||||
const projects = [];
|
||||
|
||||
if (result.success) {
|
||||
for (const projectId of result.projects) {
|
||||
const project = await getProjectInfo(projectId);
|
||||
|
||||
projects.push({
|
||||
id: projectId,
|
||||
name: project?.name ?? projectId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
populateSelect(document.getElementById('taskFormProjectId'), projects, {
|
||||
placeholder: 'Select project',
|
||||
selectedValue: selectedProjectId
|
||||
});
|
||||
}
|
||||
|
||||
async function populateTaskRelationSelects(projectId = '', selected = {}) {
|
||||
const users = await getUsers();
|
||||
|
||||
populateSelect(document.getElementById('taskFormAssignee'), users, {
|
||||
placeholder: 'Unassigned',
|
||||
selectedValue: selected.assignee ?? ''
|
||||
});
|
||||
|
||||
const versions = projectId ? await getProjectVersions(projectId.toUpperCase()) : [];
|
||||
|
||||
populateSelect(document.getElementById('taskFormFixVersion'), versions, {
|
||||
placeholder: 'No fix version',
|
||||
selectedValue: selected.fix_version ?? ''
|
||||
});
|
||||
}
|
||||
|
||||
function resetTaskPopup() {
|
||||
const form = document.getElementById('createTaskForm');
|
||||
|
||||
if (!form) return;
|
||||
|
||||
form.reset();
|
||||
form.dataset.mode = 'create';
|
||||
document.getElementById('taskPopupTitle').textContent = 'Create Task';
|
||||
document.getElementById('taskPopupSubmit').textContent = 'Create Task';
|
||||
document.getElementById('taskFormTaskId').value = '';
|
||||
document.getElementById('taskFormProjectId').disabled = false;
|
||||
}
|
||||
|
||||
function resetVersionPopup() {
|
||||
const form = document.getElementById('createVersionForm');
|
||||
|
||||
if (!form) return;
|
||||
|
||||
form.reset();
|
||||
form.dataset.mode = 'create';
|
||||
document.getElementById('versionPopupTitle').textContent = 'Create Version';
|
||||
document.getElementById('versionPopupSubmit').textContent = 'Create Version';
|
||||
document.getElementById('versionFormVersionId').value = '';
|
||||
document.getElementById('versionFormProjectId').value = '';
|
||||
}
|
||||
|
||||
function populateSelect(select, options, settings = {}) {
|
||||
if (!select) return;
|
||||
|
||||
const placeholder = settings.placeholder ?? 'Select value';
|
||||
const includeEmpty = settings.includeEmpty ?? true;
|
||||
const selectedValue = settings.selectedValue ?? '';
|
||||
|
||||
select.innerHTML = '';
|
||||
|
||||
if (includeEmpty) {
|
||||
const option = document.createElement('option');
|
||||
option.value = '';
|
||||
option.textContent = placeholder;
|
||||
select.appendChild(option);
|
||||
}
|
||||
|
||||
options.forEach((item) => {
|
||||
const option = document.createElement('option');
|
||||
option.value = item.id;
|
||||
option.textContent = item.name ?? item.email ?? item.id;
|
||||
option.selected = String(option.value) === String(selectedValue);
|
||||
select.appendChild(option);
|
||||
});
|
||||
}
|
||||
|
||||
async function getUserInfo(userId) {
|
||||
if (!userId || userId === '-') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const result = await apiGet('/api/user.php', {
|
||||
api: 'UserInfo',
|
||||
user_id: userId
|
||||
});
|
||||
|
||||
return result.success ? result.user : null;
|
||||
}
|
||||
|
||||
function renderUser(user) {
|
||||
if (!user) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
const avatar = user.picture
|
||||
? `<img src="${user.picture}" alt="" class="user-avatar">`
|
||||
: `
|
||||
<span class="user-avatar user-avatar-fallback">
|
||||
<i class="fa-solid fa-user"></i>
|
||||
</span>
|
||||
`;
|
||||
|
||||
return `
|
||||
<span class="meta-user">
|
||||
${avatar}
|
||||
<span>${escapeHtml(user.name)}</span>
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
|
||||
function getCurrentUserId() {
|
||||
return document.querySelector('.kiln-app')?.dataset.currentUserId ?? '';
|
||||
}
|
||||
|
||||
function updateAccountProfile(user) {
|
||||
setText('accountName', user.name ?? '');
|
||||
setText('accountEmail', user.email ?? '');
|
||||
|
||||
const accountButton = document.querySelector('.kiln-account-btn');
|
||||
const accountName = document.getElementById('accountName');
|
||||
|
||||
if (!accountButton || !accountName) return;
|
||||
|
||||
document.getElementById('accountAvatarImage')?.remove();
|
||||
document.getElementById('accountAvatarFallback')?.remove();
|
||||
|
||||
const avatar = document.createElement(user.picture ? 'img' : 'span');
|
||||
avatar.id = user.picture ? 'accountAvatarImage' : 'accountAvatarFallback';
|
||||
avatar.className = user.picture ? 'user-avatar' : 'user-avatar user-avatar-fallback';
|
||||
|
||||
if (user.picture) {
|
||||
avatar.src = user.picture;
|
||||
avatar.alt = '';
|
||||
} else {
|
||||
avatar.innerHTML = '<i class="fa-solid fa-user"></i>';
|
||||
}
|
||||
|
||||
accountButton.insertBefore(avatar, accountName);
|
||||
}
|
||||
|
||||
function applyTheme(theme) {
|
||||
const stylesheet = document.getElementById('themeStylesheet');
|
||||
|
||||
if (!stylesheet || !themeStyles[theme]) return;
|
||||
|
||||
stylesheet.href = themeStyles[theme];
|
||||
stylesheet.dataset.theme = theme;
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value)
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
15
ProjectKiln/app/js/home/dashboard.js
Normal file
15
ProjectKiln/app/js/home/dashboard.js
Normal file
@@ -0,0 +1,15 @@
|
||||
function loadDashboard(pushHistory = true) {
|
||||
showView('dashboardView');
|
||||
|
||||
if (!pushHistory) return;
|
||||
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('page', 'home');
|
||||
url.searchParams.delete('task');
|
||||
url.searchParams.delete('project');
|
||||
url.searchParams.delete('tasks');
|
||||
url.searchParams.delete('version');
|
||||
url.searchParams.delete('profile');
|
||||
url.searchParams.delete('admin');
|
||||
window.history.pushState({}, '', url);
|
||||
}
|
||||
623
ProjectKiln/app/js/home/events.js
Normal file
623
ProjectKiln/app/js/home/events.js
Normal file
@@ -0,0 +1,623 @@
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
projectTreePromise = loadProjectTree();
|
||||
populateTaskOptionSelects();
|
||||
applyPermissionVisibility();
|
||||
initTaskInlineEditing();
|
||||
initVersionInlineEditing();
|
||||
initProjectInlineEditing();
|
||||
|
||||
loadRouteFromUrl();
|
||||
|
||||
window.addEventListener('popstate', () => {
|
||||
loadRouteFromUrl();
|
||||
});
|
||||
|
||||
document.querySelectorAll('[data-view="dashboard"]').forEach((button) => {
|
||||
button.addEventListener('click', () => {
|
||||
loadDashboard();
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('adminMenuButton')?.addEventListener('click', () => {
|
||||
loadAdmin('workflows');
|
||||
});
|
||||
|
||||
document.querySelectorAll('[data-admin-section]').forEach((button) => {
|
||||
button.addEventListener('click', () => {
|
||||
loadAdmin(button.dataset.adminSection);
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('workflowRefreshButton')?.addEventListener('click', () => {
|
||||
loadWorkflowEditor();
|
||||
});
|
||||
|
||||
document.getElementById('workflowTaskTypeSelect')?.addEventListener('change', (event) => {
|
||||
selectedWorkflowTaskType = event.target.value || null;
|
||||
renderWorkflowEditor();
|
||||
});
|
||||
|
||||
document.getElementById('workflowDefaultStateSelect')?.addEventListener('change', (event) => {
|
||||
if (!selectedWorkflowTaskType) return;
|
||||
|
||||
saveWorkflowAction('SetDefaultState', {
|
||||
task_type: selectedWorkflowTaskType,
|
||||
state: event.target.value
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('workflowStateForm')?.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
const form = event.currentTarget;
|
||||
const data = Object.fromEntries(new FormData(form));
|
||||
const saved = await saveWorkflowAction('CreateState', data);
|
||||
|
||||
if (saved) form.reset();
|
||||
});
|
||||
|
||||
document.getElementById('workflowTransitionForm')?.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
const form = event.currentTarget;
|
||||
const data = Object.fromEntries(new FormData(form));
|
||||
const saved = await saveWorkflowAction('CreateTransition', data);
|
||||
|
||||
if (saved) form.reset();
|
||||
});
|
||||
|
||||
document.getElementById('workflowAssignmentForm')?.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
const form = event.currentTarget;
|
||||
const data = Object.fromEntries(new FormData(form));
|
||||
data.task_type = selectedWorkflowTaskType;
|
||||
|
||||
if (!data.task_type) {
|
||||
alert('Select a task type first.');
|
||||
return;
|
||||
}
|
||||
|
||||
const saved = await saveWorkflowAction('AssignState', data);
|
||||
|
||||
if (saved) form.reset();
|
||||
});
|
||||
|
||||
document.getElementById('adminWorkflowsPanel')?.addEventListener('click', (event) => {
|
||||
const stateButton = event.target.closest('[data-workflow-delete-state]');
|
||||
const transitionButton = event.target.closest('[data-workflow-delete-transition]');
|
||||
const assignmentButton = event.target.closest('[data-workflow-remove-assignment]');
|
||||
|
||||
if (stateButton) {
|
||||
if (!window.confirm('Delete this state and related transitions/assignments?')) return;
|
||||
|
||||
saveWorkflowAction('DeleteState', {}, {
|
||||
state_id: stateButton.dataset.workflowDeleteState
|
||||
});
|
||||
}
|
||||
|
||||
if (transitionButton) {
|
||||
saveWorkflowAction('DeleteTransition', {}, {
|
||||
transition_id: transitionButton.dataset.workflowDeleteTransition
|
||||
});
|
||||
}
|
||||
|
||||
if (assignmentButton) {
|
||||
saveWorkflowAction('RemoveAssignedState', {}, {
|
||||
assignment_id: assignmentButton.dataset.workflowRemoveAssignment
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelectorAll('[data-admin-option-form]').forEach((form) => {
|
||||
form.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
const saved = await saveAdminOptionForm(form.dataset.adminOptionForm, form);
|
||||
|
||||
if (saved) form.reset();
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('adminTaskTypeSelect')?.addEventListener('change', (event) => {
|
||||
selectedAdminTaskType = event.target.value || null;
|
||||
renderAdminOptions();
|
||||
});
|
||||
|
||||
document.getElementById('adminUserSelect')?.addEventListener('change', (event) => {
|
||||
selectedAdminUser = event.target.value || null;
|
||||
renderAdminOptions();
|
||||
});
|
||||
|
||||
document.getElementById('adminCustomFieldForm')?.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
const saved = await saveAdminCustomFieldForm(event.currentTarget);
|
||||
|
||||
if (saved) event.currentTarget.reset();
|
||||
});
|
||||
|
||||
document.getElementById('adminOptionsPanel')?.addEventListener('submit', async (event) => {
|
||||
const form = event.target.closest('[data-admin-option-edit]');
|
||||
|
||||
if (!form) return;
|
||||
|
||||
event.preventDefault();
|
||||
await saveAdminOptionForm(form.dataset.adminOptionEdit, form, form.dataset.optionId);
|
||||
});
|
||||
|
||||
document.getElementById('adminOptionsPanel')?.addEventListener('submit', async (event) => {
|
||||
const form = event.target.closest('[data-admin-custom-field-edit]');
|
||||
|
||||
if (!form) return;
|
||||
|
||||
event.preventDefault();
|
||||
await saveAdminCustomFieldForm(form, form.dataset.adminCustomFieldEdit);
|
||||
});
|
||||
|
||||
document.getElementById('adminOptionsPanel')?.addEventListener('click', (event) => {
|
||||
const deleteButton = event.target.closest('[data-admin-option-delete]');
|
||||
|
||||
if (!deleteButton) return;
|
||||
|
||||
if (!window.confirm('Delete this option?')) return;
|
||||
|
||||
deleteAdminOption(deleteButton.dataset.adminOptionDelete, deleteButton.dataset.optionId);
|
||||
});
|
||||
|
||||
document.getElementById('adminOptionsPanel')?.addEventListener('click', (event) => {
|
||||
const deleteButton = event.target.closest('[data-admin-custom-field-delete]');
|
||||
|
||||
if (!deleteButton) return;
|
||||
|
||||
if (!window.confirm('Delete this custom field?')) return;
|
||||
|
||||
deleteAdminCustomField(deleteButton.dataset.adminCustomFieldDelete);
|
||||
});
|
||||
|
||||
document.getElementById('adminUsersPanel')?.addEventListener('change', async (event) => {
|
||||
const checkbox = event.target.closest('[data-admin-user-right]');
|
||||
|
||||
if (!checkbox) return;
|
||||
|
||||
checkbox.disabled = true;
|
||||
const saved = await setAdminUserRight(
|
||||
checkbox.dataset.userId,
|
||||
checkbox.dataset.rightId,
|
||||
checkbox.checked
|
||||
);
|
||||
|
||||
if (!saved) {
|
||||
checkbox.checked = !checkbox.checked;
|
||||
checkbox.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('adminUsersPanel')?.addEventListener('submit', async (event) => {
|
||||
const form = event.target.closest('#adminProjectAccessForm');
|
||||
|
||||
if (!form) return;
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
if (!selectedAdminUser) return;
|
||||
|
||||
const data = Object.fromEntries(new FormData(form));
|
||||
if (!data.project_id) return;
|
||||
|
||||
await grantAdminProjectAccess(selectedAdminUser, data.project_id);
|
||||
});
|
||||
|
||||
document.getElementById('adminUsersPanel')?.addEventListener('click', async (event) => {
|
||||
const deleteButton = event.target.closest('[data-admin-project-access-delete]');
|
||||
|
||||
if (!deleteButton) return;
|
||||
|
||||
await revokeAdminProjectAccess(deleteButton.dataset.adminProjectAccessDelete);
|
||||
});
|
||||
|
||||
document.getElementById('profileMenuButton')?.addEventListener('click', () => {
|
||||
loadProfile();
|
||||
});
|
||||
|
||||
document.getElementById('taskEditButton')?.addEventListener('click', () => {
|
||||
if (!canEditTasks()) return;
|
||||
openTaskEditPopup();
|
||||
});
|
||||
|
||||
document.getElementById('taskView')?.addEventListener('click', (event) => {
|
||||
const customField = event.target.closest('[data-custom-field-id]');
|
||||
|
||||
if (customField) {
|
||||
if (!canEditTasks()) return;
|
||||
openCustomFieldInlineEditor(customField);
|
||||
return;
|
||||
}
|
||||
|
||||
const statusButton = event.target.closest('#taskStatusButton');
|
||||
|
||||
if (statusButton) {
|
||||
const menu = document.getElementById('taskStatusMenu');
|
||||
if (menu) menu.hidden = !menu.hidden;
|
||||
return;
|
||||
}
|
||||
|
||||
const transitionButton = event.target.closest('[data-task-transition]');
|
||||
|
||||
if (transitionButton) {
|
||||
transitionCurrentTask(transitionButton.dataset.taskTransition);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('taskView')?.addEventListener('keydown', (event) => {
|
||||
if (['INPUT', 'TEXTAREA', 'SELECT', 'BUTTON'].includes(event.target.tagName)) return;
|
||||
|
||||
const customField = event.target.closest('[data-custom-field-id]');
|
||||
|
||||
if (!customField || !['Enter', ' '].includes(event.key)) return;
|
||||
|
||||
event.preventDefault();
|
||||
if (!canEditTasks()) return;
|
||||
openCustomFieldInlineEditor(customField);
|
||||
});
|
||||
|
||||
document.getElementById('taskCommentButton')?.addEventListener('click', () => {
|
||||
document.getElementById('taskCommentInput')?.focus();
|
||||
});
|
||||
|
||||
document.getElementById('versionEditButton')?.addEventListener('click', () => {
|
||||
if (!canEditVersions()) return;
|
||||
openVersionEditPopup();
|
||||
});
|
||||
|
||||
document.getElementById('projectEditButton')?.addEventListener('click', () => {
|
||||
if (!canEditProjects()) return;
|
||||
openProjectEditPopup();
|
||||
});
|
||||
|
||||
document.querySelectorAll('[data-version-task-sort]').forEach((button) => {
|
||||
button.addEventListener('click', () => {
|
||||
const field = button.dataset.versionTaskSort;
|
||||
|
||||
if (versionTaskSort.field === field) {
|
||||
versionTaskSort.direction = versionTaskSort.direction === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
versionTaskSort.field = field;
|
||||
versionTaskSort.direction = 'asc';
|
||||
}
|
||||
|
||||
if (currentVersion) {
|
||||
loadVersionTasks(currentVersion.id, 1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('[data-project-task-sort]').forEach((button) => {
|
||||
button.addEventListener('click', () => {
|
||||
const field = button.dataset.projectTaskSort;
|
||||
|
||||
if (projectTaskSort.field === field) {
|
||||
projectTaskSort.direction = projectTaskSort.direction === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
projectTaskSort.field = field;
|
||||
projectTaskSort.direction = 'asc';
|
||||
}
|
||||
|
||||
if (currentProjectTaskProject) {
|
||||
loadProjectTasks(currentProjectTaskProject, 1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.addEventListener('click', (event) => {
|
||||
const pageButton = event.target.closest('[data-task-page]');
|
||||
|
||||
if (!pageButton || pageButton.disabled) return;
|
||||
|
||||
const page = Number(pageButton.dataset.page);
|
||||
|
||||
if (!Number.isFinite(page) || page < 1) return;
|
||||
|
||||
if (pageButton.dataset.taskPage === 'version' && currentVersion) {
|
||||
loadVersionTasks(currentVersion.id, page);
|
||||
}
|
||||
|
||||
if (pageButton.dataset.taskPage === 'project' && currentProjectTaskProject) {
|
||||
loadProjectTasks(currentProjectTaskProject, page);
|
||||
}
|
||||
});
|
||||
|
||||
const createProjectForm = document.getElementById('createProjectForm');
|
||||
|
||||
if (createProjectForm) {
|
||||
createProjectForm.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
const data = Object.fromEntries(new FormData(createProjectForm));
|
||||
const mode = createProjectForm.dataset.mode ?? 'create';
|
||||
const projectId = document.getElementById('projectFormId').value;
|
||||
|
||||
const result = await apiPost('/api/project.php', {
|
||||
api: mode === 'edit' ? 'Edit' : 'Create',
|
||||
project_id: mode === 'edit' ? projectId : undefined
|
||||
}, data);
|
||||
|
||||
if (result.success) {
|
||||
taskLookupCache.projectsById.delete(result.project_id);
|
||||
closePopups();
|
||||
createProjectForm.reset();
|
||||
projectTreePromise = loadProjectTree();
|
||||
|
||||
if (mode === 'edit') {
|
||||
await loadProject(projectId, false);
|
||||
}
|
||||
} else {
|
||||
alert(result.error || 'Could not create project.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const createVersionForm = document.getElementById('createVersionForm');
|
||||
|
||||
if (createVersionForm) {
|
||||
createVersionForm.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
const data = Object.fromEntries(new FormData(createVersionForm));
|
||||
const mode = createVersionForm.dataset.mode ?? 'create';
|
||||
const versionId = data.version_id;
|
||||
const projectId = data.project_id;
|
||||
delete data.version_id;
|
||||
|
||||
if (mode === 'edit') {
|
||||
delete data.project_id;
|
||||
}
|
||||
|
||||
const result = await apiPost('/api/version.php', {
|
||||
api: mode === 'edit' ? 'Edit' : 'Create',
|
||||
version_id: mode === 'edit' ? versionId : undefined
|
||||
}, data);
|
||||
|
||||
if (result.success) {
|
||||
taskLookupCache.versionsByProject.delete(projectId);
|
||||
closePopups();
|
||||
projectTreePromise = loadProjectTree();
|
||||
|
||||
if (mode === 'edit') {
|
||||
await loadVersion(versionId, false);
|
||||
}
|
||||
} else {
|
||||
alert(result.error || 'Could not save version.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const profileForm = document.getElementById('profileForm');
|
||||
|
||||
if (profileForm) {
|
||||
document.getElementById('profilePictureInput')?.addEventListener('change', (event) => {
|
||||
const file = event.target.files?.[0];
|
||||
|
||||
if (!file) {
|
||||
renderProfileAvatar(currentProfileUser?.picture ?? null);
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('profileRemovePicture').checked = false;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.addEventListener('load', () => renderProfileAvatar(reader.result));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
document.getElementById('profileRemovePicture')?.addEventListener('change', (event) => {
|
||||
if (event.target.checked) {
|
||||
document.getElementById('profilePictureInput').value = '';
|
||||
renderProfileAvatar(null);
|
||||
} else {
|
||||
renderProfileAvatar(currentProfileUser?.picture ?? null);
|
||||
}
|
||||
});
|
||||
|
||||
profileForm.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
const password = document.getElementById('profilePassword').value;
|
||||
const passwordConfirm = document.getElementById('profilePasswordConfirm').value;
|
||||
const status = document.getElementById('profileStatus');
|
||||
const submitButton = document.getElementById('profileSubmitButton');
|
||||
|
||||
if (password !== passwordConfirm) {
|
||||
status.textContent = 'Passwords do not match.';
|
||||
return;
|
||||
}
|
||||
|
||||
const data = new FormData(profileForm);
|
||||
|
||||
if (!password) {
|
||||
data.delete('password');
|
||||
}
|
||||
|
||||
submitButton.disabled = true;
|
||||
status.textContent = 'Saving...';
|
||||
|
||||
const result = await apiPostForm('/api/user.php', {
|
||||
api: 'Edit',
|
||||
user_id: getCurrentUserId()
|
||||
}, data);
|
||||
|
||||
submitButton.disabled = false;
|
||||
|
||||
if (!result.success) {
|
||||
status.textContent = result.error || 'Could not save profile.';
|
||||
return;
|
||||
}
|
||||
|
||||
const userResult = await apiGet('/api/user.php', {
|
||||
api: 'UserInfo',
|
||||
user_id: getCurrentUserId(),
|
||||
_: Date.now()
|
||||
});
|
||||
|
||||
if (userResult.success) {
|
||||
taskLookupCache.users = null;
|
||||
updateAccountProfile(userResult.user);
|
||||
applyTheme(userResult.user.settings?.theme ?? 'dark');
|
||||
renderProfile(userResult.user);
|
||||
status.textContent = 'Saved.';
|
||||
} else {
|
||||
status.textContent = 'Saved. Refresh to see all changes.';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const createTaskForm = document.getElementById('createTaskForm');
|
||||
|
||||
if (createTaskForm) {
|
||||
document.getElementById('taskFormProjectId')?.addEventListener('change', (event) => {
|
||||
populateTaskRelationSelects(event.target.value.trim());
|
||||
});
|
||||
|
||||
createTaskForm.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
const data = Object.fromEntries(new FormData(createTaskForm));
|
||||
const mode = createTaskForm.dataset.mode ?? 'create';
|
||||
const taskId = data.task_id;
|
||||
delete data.task_id;
|
||||
|
||||
if (mode === 'edit') {
|
||||
delete data.project_id;
|
||||
}
|
||||
|
||||
const result = await apiPost('/api/task.php', {
|
||||
api: mode === 'edit' ? 'Edit' : 'Create',
|
||||
task_id: mode === 'edit' ? taskId : undefined
|
||||
}, data);
|
||||
|
||||
if (result.success) {
|
||||
closePopups();
|
||||
projectTreePromise = loadProjectTree();
|
||||
loadTask(mode === 'edit' ? taskId : result.task_id, mode !== 'edit');
|
||||
} else {
|
||||
alert(result.error || 'Could not save task.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const taskCommentForm = document.getElementById('taskCommentForm');
|
||||
|
||||
if (taskCommentForm) {
|
||||
taskCommentForm.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
const input = document.getElementById('taskCommentInput');
|
||||
const status = document.getElementById('taskCommentStatus');
|
||||
const comment = input.value.trim();
|
||||
|
||||
if (!comment) {
|
||||
status.textContent = 'Comment cannot be empty.';
|
||||
return;
|
||||
}
|
||||
|
||||
status.textContent = 'Saving...';
|
||||
|
||||
try {
|
||||
await submitTaskComment(comment);
|
||||
input.value = '';
|
||||
status.textContent = 'Saved.';
|
||||
} catch (error) {
|
||||
status.textContent = error.message;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('taskCommentList')?.addEventListener('click', (event) => {
|
||||
const replyButton = event.target.closest('[data-comment-reply]');
|
||||
const editButton = event.target.closest('[data-comment-edit]');
|
||||
const deleteButton = event.target.closest('[data-comment-delete]');
|
||||
const cancelButton = event.target.closest('[data-comment-reply-cancel]');
|
||||
const editCancelButton = event.target.closest('[data-comment-edit-cancel]');
|
||||
|
||||
if (replyButton) {
|
||||
const commentCard = replyButton.closest('.task-comment');
|
||||
const slot = commentCard?.querySelector('.task-comment-reply-slot');
|
||||
|
||||
if (!slot || slot.querySelector('form')) return;
|
||||
|
||||
slot.innerHTML = `
|
||||
<form class="task-comment-reply-form" data-comment-reply-form="${replyButton.dataset.commentReply}">
|
||||
<textarea class="form-control" name="comment" rows="2" placeholder="Write a reply..."></textarea>
|
||||
<div class="task-comment-reply-actions">
|
||||
<button class="btn btn-sm btn-primary" type="submit">Reply</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" type="button" data-comment-reply-cancel>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
`;
|
||||
slot.querySelector('textarea')?.focus();
|
||||
}
|
||||
|
||||
if (editButton) {
|
||||
const commentCard = editButton.closest('.task-comment');
|
||||
const slot = commentCard?.querySelector('.task-comment-edit-slot');
|
||||
|
||||
if (!slot || slot.querySelector('form')) return;
|
||||
|
||||
slot.innerHTML = `
|
||||
<form class="task-comment-edit-form" data-comment-edit-form="${editButton.dataset.commentEdit}">
|
||||
<textarea class="form-control" name="comment" rows="3">${escapeHtml(commentCard.dataset.commentText ?? '')}</textarea>
|
||||
<div class="task-comment-reply-actions">
|
||||
<button class="btn btn-sm btn-primary" type="submit">Save</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" type="button" data-comment-edit-cancel>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
`;
|
||||
slot.querySelector('textarea')?.focus();
|
||||
}
|
||||
|
||||
if (deleteButton) {
|
||||
if (!window.confirm('Delete this comment?')) return;
|
||||
|
||||
deleteTaskComment(deleteButton.dataset.commentDelete).catch((error) => {
|
||||
alert(error.message);
|
||||
});
|
||||
}
|
||||
|
||||
if (cancelButton) {
|
||||
cancelButton.closest('.task-comment-reply-slot').innerHTML = '';
|
||||
}
|
||||
|
||||
if (editCancelButton) {
|
||||
editCancelButton.closest('.task-comment-edit-slot').innerHTML = '';
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('taskCommentList')?.addEventListener('submit', async (event) => {
|
||||
const form = event.target.closest('[data-comment-reply-form], [data-comment-edit-form]');
|
||||
|
||||
if (!form) return;
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
const textarea = form.querySelector('textarea');
|
||||
const button = form.querySelector('button[type="submit"]');
|
||||
const comment = textarea.value.trim();
|
||||
const responseTo = form.dataset.commentReplyForm;
|
||||
const editCommentId = form.dataset.commentEditForm;
|
||||
|
||||
if (!comment) return;
|
||||
|
||||
button.disabled = true;
|
||||
|
||||
try {
|
||||
if (editCommentId) {
|
||||
await updateTaskComment(editCommentId, comment);
|
||||
} else {
|
||||
await submitTaskComment(comment, responseTo);
|
||||
}
|
||||
} catch (error) {
|
||||
alert(error.message);
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
63
ProjectKiln/app/js/home/profile.js
Normal file
63
ProjectKiln/app/js/home/profile.js
Normal file
@@ -0,0 +1,63 @@
|
||||
async function loadProfile(pushHistory = true) {
|
||||
const view = showView('profileView');
|
||||
|
||||
if (!view) return;
|
||||
|
||||
if (pushHistory) {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('page', 'home');
|
||||
url.searchParams.set('profile', '1');
|
||||
url.searchParams.delete('task');
|
||||
url.searchParams.delete('project');
|
||||
url.searchParams.delete('tasks');
|
||||
url.searchParams.delete('version');
|
||||
url.searchParams.delete('admin');
|
||||
window.history.pushState({}, '', url);
|
||||
}
|
||||
|
||||
const userId = getCurrentUserId();
|
||||
const result = await apiGet('/api/user.php', {
|
||||
api: 'UserInfo',
|
||||
user_id: userId,
|
||||
_: Date.now()
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
view.innerHTML = '<div class="alert alert-danger">Could not load profile.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
renderProfile(result.user);
|
||||
}
|
||||
|
||||
function renderProfile(user) {
|
||||
currentProfileUser = user;
|
||||
|
||||
setText('profileStatus', '');
|
||||
document.getElementById('profileName').value = user.name ?? '';
|
||||
document.getElementById('profileEmail').value = user.email ?? '';
|
||||
document.getElementById('profilePassword').value = '';
|
||||
document.getElementById('profilePasswordConfirm').value = '';
|
||||
document.getElementById('profileTheme').value = user.settings?.theme ?? 'dark';
|
||||
document.getElementById('profileRemovePicture').checked = false;
|
||||
document.getElementById('profilePictureInput').value = '';
|
||||
renderProfileAvatar(user.picture);
|
||||
}
|
||||
|
||||
function renderProfileAvatar(picture) {
|
||||
const preview = document.getElementById('profileAvatarPreview');
|
||||
|
||||
if (!preview) return;
|
||||
|
||||
if (picture) {
|
||||
preview.innerHTML = `<img src="${picture}" alt="">`;
|
||||
return;
|
||||
}
|
||||
|
||||
preview.innerHTML = `
|
||||
<span class="profile-avatar-fallback">
|
||||
<i class="fa-solid fa-user"></i>
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
|
||||
397
ProjectKiln/app/js/home/project.js
Normal file
397
ProjectKiln/app/js/home/project.js
Normal file
@@ -0,0 +1,397 @@
|
||||
async function loadProjectTree() {
|
||||
const tree = document.getElementById('projectTree');
|
||||
|
||||
if (!tree) return;
|
||||
|
||||
tree.innerHTML = '<div class="kiln-tree-muted">Loading projects...</div>';
|
||||
|
||||
const result = await apiGet('/api/project.php', {
|
||||
api: 'ListProjects'
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
tree.innerHTML = '<div class="text-danger">Could not load projects.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.projects.length) {
|
||||
tree.innerHTML = '<div class="kiln-tree-muted">No projects yet.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
tree.innerHTML = '';
|
||||
taskLookupCache.projectBlocksById.clear();
|
||||
|
||||
for (const projectId of result.projects) {
|
||||
const project = await getProjectInfo(projectId);
|
||||
const projectBlock = document.createElement('div');
|
||||
projectBlock.dataset.projectId = projectId;
|
||||
|
||||
const projectButton = document.createElement('button');
|
||||
projectButton.className = 'kiln-tree-project';
|
||||
projectButton.innerHTML = `
|
||||
<i class="fa-solid fa-caret-right me-1" data-project-caret></i>
|
||||
<span>${escapeHtml(project?.name ?? projectId)}</span>
|
||||
`;
|
||||
projectButton.addEventListener('click', (event) => {
|
||||
if (event.target.closest('[data-project-caret]')) {
|
||||
toggleProject(projectId, projectBlock);
|
||||
return;
|
||||
}
|
||||
|
||||
loadProject(projectId);
|
||||
|
||||
if (!projectBlock.querySelector('.kiln-tree-group')) {
|
||||
toggleProject(projectId, projectBlock, true);
|
||||
}
|
||||
});
|
||||
|
||||
projectBlock.appendChild(projectButton);
|
||||
tree.appendChild(projectBlock);
|
||||
taskLookupCache.projectBlocksById.set(projectId, projectBlock);
|
||||
}
|
||||
|
||||
if (currentTask?.project) {
|
||||
const projectBlock = taskLookupCache.projectBlocksById.get(currentTask.project);
|
||||
|
||||
if (projectBlock && !projectBlock.querySelector('.kiln-tree-group')) {
|
||||
await toggleProject(currentTask.project, projectBlock, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function getProjectInfo(projectId) {
|
||||
if (taskLookupCache.projectsById.has(projectId)) {
|
||||
return taskLookupCache.projectsById.get(projectId);
|
||||
}
|
||||
|
||||
const result = await apiGet('/api/project.php', {
|
||||
api: 'ProjectInfo',
|
||||
project_id: projectId
|
||||
});
|
||||
|
||||
const project = result.success ? result.project : null;
|
||||
taskLookupCache.projectsById.set(projectId, project);
|
||||
return project;
|
||||
}
|
||||
|
||||
async function ensureProjectOpen(projectId) {
|
||||
await projectTreePromise;
|
||||
|
||||
const projectBlock = taskLookupCache.projectBlocksById.get(projectId);
|
||||
|
||||
if (!projectBlock || projectBlock.querySelector('.kiln-tree-group')) {
|
||||
return;
|
||||
}
|
||||
|
||||
await toggleProject(projectId, projectBlock, true);
|
||||
}
|
||||
|
||||
async function toggleProject(projectId, projectBlock, forceOpen = false) {
|
||||
let group = projectBlock.querySelector('.kiln-tree-group');
|
||||
|
||||
if (group) {
|
||||
if (forceOpen) return;
|
||||
|
||||
group.remove();
|
||||
setProjectCaret(projectBlock, false);
|
||||
return;
|
||||
}
|
||||
|
||||
setProjectCaret(projectBlock, true);
|
||||
|
||||
group = document.createElement('div');
|
||||
group.className = 'kiln-tree-group';
|
||||
group.innerHTML = '<div class="kiln-tree-muted">Loading...</div>';
|
||||
projectBlock.appendChild(group);
|
||||
|
||||
const versions = await apiGet('/api/version.php', {
|
||||
api: 'ListVersions',
|
||||
project_id: projectId
|
||||
});
|
||||
|
||||
group.innerHTML = '';
|
||||
|
||||
const versionGroup = document.createElement('div');
|
||||
versionGroup.className = 'kiln-tree-version-group';
|
||||
group.appendChild(versionGroup);
|
||||
|
||||
const versionLabel = document.createElement('div');
|
||||
versionLabel.className = 'kiln-tree-label';
|
||||
versionLabel.textContent = 'Versions';
|
||||
versionGroup.appendChild(versionLabel);
|
||||
|
||||
if (document.querySelector('.kiln-app')?.dataset.canCreateVersions === '1') {
|
||||
const createVersion = document.createElement('button');
|
||||
createVersion.className = 'kiln-tree-version';
|
||||
createVersion.innerHTML = '<i class="fa-solid fa-plus me-1"></i>Create new version';
|
||||
createVersion.addEventListener('click', () => openVersionCreatePopup(projectId));
|
||||
versionGroup.appendChild(createVersion);
|
||||
}
|
||||
|
||||
if (versions.success && versions.versions.length) {
|
||||
for (const versionId of versions.versions) {
|
||||
const versionInfo = await apiGet('/api/version.php', {
|
||||
api: 'VersionInfo',
|
||||
version_id: versionId
|
||||
});
|
||||
|
||||
const versionButton = document.createElement('button');
|
||||
versionButton.className = 'kiln-tree-version';
|
||||
|
||||
if (versionInfo.success && versionInfo.version) {
|
||||
versionButton.textContent = versionInfo.version.name;
|
||||
} else {
|
||||
versionButton.textContent = `Version ${versionId}`;
|
||||
}
|
||||
|
||||
versionButton.addEventListener('click', () => loadVersion(versionId));
|
||||
versionGroup.appendChild(versionButton);
|
||||
}
|
||||
}
|
||||
|
||||
const showTasksButton = document.createElement('button');
|
||||
showTasksButton.className = 'kiln-tree-version';
|
||||
showTasksButton.innerHTML = '<i class="fa-solid fa-list-check me-1"></i>Show all tasks';
|
||||
showTasksButton.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
loadProjectTasks(projectId);
|
||||
});
|
||||
|
||||
group.appendChild(showTasksButton);
|
||||
}
|
||||
|
||||
function setProjectCaret(projectBlock, isOpen) {
|
||||
const caret = projectBlock.querySelector('[data-project-caret]');
|
||||
|
||||
if (!caret) return;
|
||||
|
||||
caret.classList.toggle('fa-caret-right', !isOpen);
|
||||
caret.classList.toggle('fa-caret-down', isOpen);
|
||||
}
|
||||
|
||||
async function loadProject(projectId, pushHistory = true) {
|
||||
const view = showView('projectView');
|
||||
|
||||
if (!view) return;
|
||||
|
||||
if (pushHistory) {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('page', 'home');
|
||||
url.searchParams.set('project', projectId);
|
||||
url.searchParams.delete('tasks');
|
||||
url.searchParams.delete('task');
|
||||
url.searchParams.delete('version');
|
||||
url.searchParams.delete('profile');
|
||||
url.searchParams.delete('admin');
|
||||
window.history.pushState({}, '', url);
|
||||
}
|
||||
|
||||
const result = await apiGet('/api/project.php', {
|
||||
api: 'ProjectInfo',
|
||||
project_id: projectId,
|
||||
_: Date.now()
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
view.innerHTML = '<div class="alert alert-danger">Project not found.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
await renderProject(result.project);
|
||||
await ensureProjectOpen(result.project.id);
|
||||
}
|
||||
|
||||
async function renderProject(project) {
|
||||
currentProject = project;
|
||||
taskLookupCache.projectsById.set(project.id, project);
|
||||
|
||||
const owner = project.owner ? await getUserInfo(project.owner) : null;
|
||||
|
||||
setVersionEditableText('projectKey', project.id);
|
||||
setVersionEditableText('projectName', project.name, project.name);
|
||||
setVersionEditableText('projectInlineName', project.name, project.name);
|
||||
setText('projectId', project.id);
|
||||
setEditableHtml('projectOwner', renderUser(owner), project.owner ?? '', 'No owner');
|
||||
setText('projectCreated', project.created_date || '-');
|
||||
}
|
||||
|
||||
async function populateProjectOwnerSelect(selectedOwner = '') {
|
||||
populateSelect(document.getElementById('projectFormOwner'), await getUsers(), {
|
||||
includeEmpty: false,
|
||||
selectedValue: selectedOwner
|
||||
});
|
||||
}
|
||||
|
||||
function resetProjectPopup() {
|
||||
const form = document.getElementById('createProjectForm');
|
||||
|
||||
if (!form) return;
|
||||
|
||||
form.reset();
|
||||
form.dataset.mode = 'create';
|
||||
document.getElementById('projectPopupTitle').textContent = 'Create Project';
|
||||
document.getElementById('projectPopupSubmit').textContent = 'Create Project';
|
||||
document.getElementById('projectFormId').disabled = false;
|
||||
document.getElementById('projectFormId').value = '';
|
||||
document.getElementById('projectFormName').value = '';
|
||||
}
|
||||
|
||||
async function openProjectCreatePopup() {
|
||||
resetProjectPopup();
|
||||
await populateProjectOwnerSelect(getCurrentUserId());
|
||||
openPopup('createProject');
|
||||
}
|
||||
|
||||
async function openProjectEditPopup() {
|
||||
if (!currentProject) return;
|
||||
|
||||
resetProjectPopup();
|
||||
|
||||
const form = document.getElementById('createProjectForm');
|
||||
|
||||
if (!form) return;
|
||||
|
||||
form.dataset.mode = 'edit';
|
||||
document.getElementById('projectPopupTitle').textContent = `Edit ${currentProject.id}`;
|
||||
document.getElementById('projectPopupSubmit').textContent = 'Update Project';
|
||||
document.getElementById('projectFormId').value = currentProject.id;
|
||||
document.getElementById('projectFormId').disabled = true;
|
||||
document.getElementById('projectFormName').value = currentProject.name ?? '';
|
||||
await populateProjectOwnerSelect(currentProject.owner ?? '');
|
||||
|
||||
openPopup('createProject');
|
||||
}
|
||||
|
||||
function initProjectInlineEditing() {
|
||||
if (!canEditProjects()) return;
|
||||
|
||||
document.querySelectorAll('.project-editable').forEach((element) => {
|
||||
if (element.dataset.inlineReady === 'true') return;
|
||||
|
||||
element.dataset.inlineReady = 'true';
|
||||
element.title = 'Click to edit';
|
||||
|
||||
element.addEventListener('click', () => openProjectInlineEditor(element));
|
||||
element.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
openProjectInlineEditor(element);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function openProjectInlineEditor(element) {
|
||||
if (!canEditProjects() || !currentProject || element.querySelector('.version-inline-form')) return;
|
||||
|
||||
const field = element.dataset.projectField;
|
||||
const currentValue = element.dataset.currentValue ?? '';
|
||||
const originalHtml = element.innerHTML;
|
||||
const originalClasses = Array.from(element.classList);
|
||||
|
||||
if (!['name', 'owner'].includes(field)) return;
|
||||
|
||||
element.classList.remove('is-empty');
|
||||
element.innerHTML = '';
|
||||
|
||||
const form = document.createElement('form');
|
||||
form.className = 'version-inline-form';
|
||||
|
||||
let input;
|
||||
|
||||
if (field === 'owner') {
|
||||
input = document.createElement('select');
|
||||
input.className = 'form-select version-inline-input';
|
||||
populateSelect(input, await getUsers(), {
|
||||
includeEmpty: false,
|
||||
selectedValue: currentValue
|
||||
});
|
||||
} else {
|
||||
input = document.createElement('input');
|
||||
input.className = 'form-control version-inline-input';
|
||||
input.type = 'text';
|
||||
input.required = true;
|
||||
input.maxLength = 128;
|
||||
input.value = currentValue;
|
||||
}
|
||||
|
||||
form.appendChild(input);
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'version-inline-actions';
|
||||
actions.innerHTML = `
|
||||
<button class="btn btn-sm btn-primary" type="submit">
|
||||
<i class="fa-solid fa-check"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" type="button" data-project-inline-cancel>
|
||||
<i class="fa-solid fa-xmark"></i>
|
||||
</button>
|
||||
`;
|
||||
form.appendChild(actions);
|
||||
|
||||
element.appendChild(form);
|
||||
input.focus();
|
||||
if (input.select && input.tagName !== 'SELECT') {
|
||||
input.select();
|
||||
}
|
||||
|
||||
let closed = false;
|
||||
const restore = () => {
|
||||
if (closed) return;
|
||||
|
||||
closed = true;
|
||||
element.innerHTML = originalHtml;
|
||||
element.className = originalClasses.join(' ');
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
const nextValue = field === 'name' ? input.value.trim() : input.value;
|
||||
|
||||
if (nextValue === currentValue) {
|
||||
restore();
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await apiPost('/api/project.php', {
|
||||
api: 'Edit',
|
||||
project_id: currentProject.id
|
||||
}, {
|
||||
[field]: nextValue
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
alert(result.error || 'Could not update project.');
|
||||
restore();
|
||||
return;
|
||||
}
|
||||
|
||||
taskLookupCache.projectsById.delete(currentProject.id);
|
||||
closed = true;
|
||||
await loadProject(currentProject.id, false);
|
||||
projectTreePromise = loadProjectTree();
|
||||
};
|
||||
|
||||
form.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
await save();
|
||||
});
|
||||
|
||||
if (input.tagName === 'SELECT') {
|
||||
input.addEventListener('change', save);
|
||||
}
|
||||
|
||||
form.querySelector('[data-project-inline-cancel]')?.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
restore();
|
||||
});
|
||||
|
||||
input.addEventListener('keydown', async (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
restore();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
42
ProjectKiln/app/js/home/router.js
Normal file
42
ProjectKiln/app/js/home/router.js
Normal file
@@ -0,0 +1,42 @@
|
||||
function loadRouteFromUrl() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const taskId = params.get('task');
|
||||
const versionId = params.get('version');
|
||||
const projectId = params.get('project');
|
||||
const projectTasks = params.get('tasks');
|
||||
const profile = params.get('profile');
|
||||
const adminSection = params.get('admin');
|
||||
|
||||
closePopups();
|
||||
|
||||
if (adminSection) {
|
||||
loadAdmin(adminSection, false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (profile) {
|
||||
loadProfile(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (taskId) {
|
||||
loadTask(taskId, false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (versionId) {
|
||||
loadVersion(versionId, false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (projectId) {
|
||||
if (projectTasks) {
|
||||
loadProjectTasks(projectId, 1, false);
|
||||
} else {
|
||||
loadProject(projectId, false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
loadDashboard(false);
|
||||
}
|
||||
685
ProjectKiln/app/js/home/task.js
Normal file
685
ProjectKiln/app/js/home/task.js
Normal file
@@ -0,0 +1,685 @@
|
||||
async function loadTask(taskId, pushHistory = true) {
|
||||
const view = showView('taskView');
|
||||
|
||||
if (!view) return;
|
||||
|
||||
if (pushHistory) {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('page', 'home');
|
||||
url.searchParams.set('task', taskId);
|
||||
url.searchParams.delete('project');
|
||||
url.searchParams.delete('tasks');
|
||||
url.searchParams.delete('version');
|
||||
url.searchParams.delete('profile');
|
||||
url.searchParams.delete('admin');
|
||||
window.history.pushState({}, '', url);
|
||||
}
|
||||
|
||||
const result = await apiGet('/api/task.php', {
|
||||
api: 'TaskInfo',
|
||||
task_id: taskId,
|
||||
_: Date.now()
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
view.innerHTML = '<div class="alert alert-danger">Task not found.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
await renderTask(result.task);
|
||||
await loadTaskComments(result.task.id);
|
||||
await ensureProjectOpen(result.task.project);
|
||||
}
|
||||
|
||||
async function renderTask(task) {
|
||||
currentTask = task;
|
||||
|
||||
const [types, priorities, versions, reporter, assignee] = await Promise.all([
|
||||
getTaskTypes(),
|
||||
getTaskPriorities(),
|
||||
getProjectVersions(task.project),
|
||||
getUserInfo(task.reporter),
|
||||
task.assignee ? getUserInfo(task.assignee) : null
|
||||
]);
|
||||
|
||||
const type = types.find((item) => String(item.id) === String(task.type)) ?? null;
|
||||
const priority = priorities.find((item) => String(item.id) === String(task.priority)) ?? null;
|
||||
const fixVersion = versions.find((item) => String(item.id) === String(task.fix_version)) ?? null;
|
||||
|
||||
setEditableText('taskKey', task.id);
|
||||
setEditableText('taskTitle', task.title, task.title);
|
||||
setEditableText('taskDescription', task.description, task.description, 'No description provided.');
|
||||
setText('taskProject', task.project);
|
||||
document.getElementById('taskReporter').innerHTML = renderUser(reporter);
|
||||
setEditableHtml('taskAssignee', renderUser(assignee), task.assignee ?? '', 'Unassigned');
|
||||
setEditableText('taskFixVersion', fixVersion ? fixVersion.name : null, task.fix_version ?? '', 'No fix version');
|
||||
setText('taskCreated', task.created_date);
|
||||
setText('taskUpdated', task.last_changed);
|
||||
|
||||
setEditableHtml('taskType', renderMetaBadge(type), task.type);
|
||||
setEditableHtml('taskPriority', renderMetaBadge(priority), task.priority);
|
||||
renderTaskStatus(task);
|
||||
renderTaskCustomFields(task.custom_fields ?? []);
|
||||
}
|
||||
|
||||
function renderTaskStatus(task) {
|
||||
const slot = document.getElementById('taskStatusSlot');
|
||||
|
||||
if (!slot) return;
|
||||
|
||||
slot.innerHTML = '';
|
||||
|
||||
if (!task.status_state) return;
|
||||
|
||||
const isCurrentAssignee = String(task.assignee ?? '') === String(getCurrentUserId());
|
||||
const canTransition = (canEditTasks() || appFlag('isAdmin') || isCurrentAssignee)
|
||||
&& (task.status_transitions ?? []).length > 0;
|
||||
const statusStyle = `--task-status-color: ${escapeHtml(task.status_state.color ?? '#6c757d')}`;
|
||||
|
||||
if (!canTransition) {
|
||||
slot.innerHTML = `
|
||||
<span class="task-status-badge" style="${statusStyle}">
|
||||
${escapeHtml(task.status_state.name)}
|
||||
</span>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
slot.innerHTML = `
|
||||
<div class="task-status-control">
|
||||
<button class="task-status-badge task-status-button" type="button" style="${statusStyle}" id="taskStatusButton">
|
||||
<span>${escapeHtml(task.status_state.name)}</span>
|
||||
<i class="fa-solid fa-chevron-down"></i>
|
||||
</button>
|
||||
<div class="task-status-menu" id="taskStatusMenu" hidden>
|
||||
${(task.status_transitions ?? []).map((transition) => `
|
||||
<button type="button" data-task-transition="${escapeHtml(transition.id)}">
|
||||
<span>${escapeHtml(transition.action_name)}</span>
|
||||
<small>${escapeHtml(transition.to_state?.name ?? '')}</small>
|
||||
</button>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderTaskCustomFields(fields) {
|
||||
const panel = document.getElementById('taskCustomFieldsPanel');
|
||||
const list = document.getElementById('taskCustomFieldList');
|
||||
|
||||
if (!panel || !list) return;
|
||||
|
||||
const canEdit = canEditTasks();
|
||||
|
||||
panel.hidden = fields.length === 0;
|
||||
list.innerHTML = fields.map((field) => `
|
||||
<div class="task-custom-field-row">
|
||||
<span>${escapeHtml(field.name)}</span>
|
||||
<strong
|
||||
class="task-custom-field-value ${field.raw_value ? '' : 'is-empty'} ${canEdit ? '' : 'is-readonly'}"
|
||||
${canEdit ? 'tabindex="0"' : 'aria-disabled="true"'}
|
||||
data-custom-field-id="${escapeHtml(field.id)}"
|
||||
data-custom-field-type="${escapeHtml(field.type)}"
|
||||
data-current-value="${escapeHtml(field.raw_value ?? '')}"
|
||||
>${field.raw_value ? escapeHtml(field.raw_value) : 'No value'}</strong>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
async function loadTaskComments(taskId) {
|
||||
const list = document.getElementById('taskCommentList');
|
||||
const empty = document.getElementById('taskCommentEmpty');
|
||||
|
||||
if (!list || !empty) return;
|
||||
|
||||
list.innerHTML = '';
|
||||
empty.hidden = true;
|
||||
currentTaskComments = [];
|
||||
setText('taskCommentStatus', '');
|
||||
|
||||
const result = await apiGet('/api/task.php', {
|
||||
api: 'ListComments',
|
||||
task_id: taskId,
|
||||
_: Date.now()
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
list.innerHTML = '<div class="alert alert-danger mb-0">Could not load comments.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
currentTaskComments = result.comments ?? [];
|
||||
renderTaskComments();
|
||||
}
|
||||
|
||||
function renderTaskComments() {
|
||||
const list = document.getElementById('taskCommentList');
|
||||
const empty = document.getElementById('taskCommentEmpty');
|
||||
|
||||
if (!list || !empty) return;
|
||||
|
||||
list.innerHTML = '';
|
||||
empty.hidden = currentTaskComments.length > 0;
|
||||
|
||||
const commentsByParent = groupCommentsByParent(currentTaskComments);
|
||||
const renderComment = (comment, depth = 0) => {
|
||||
const user = {
|
||||
id: comment.commenter,
|
||||
name: comment.commenter_name,
|
||||
email: comment.commenter_email,
|
||||
picture: comment.commenter_picture
|
||||
};
|
||||
const article = document.createElement('article');
|
||||
const canManageComment = String(comment.commenter) === String(getCurrentUserId());
|
||||
const manageActions = canManageComment
|
||||
? `
|
||||
<button class="task-comment-action" type="button" data-comment-edit="${escapeHtml(comment.id)}">Edit</button>
|
||||
<button class="task-comment-action text-danger" type="button" data-comment-delete="${escapeHtml(comment.id)}">Delete</button>
|
||||
`
|
||||
: '';
|
||||
article.className = `task-comment${comment.response_to ? ' is-reply' : ''}`;
|
||||
article.style.setProperty('--comment-depth', String(Math.min(depth, 8)));
|
||||
article.dataset.commentId = comment.id;
|
||||
article.dataset.commentText = comment.comment;
|
||||
article.innerHTML = `
|
||||
<div class="task-comment-header">
|
||||
${renderUser(user)}
|
||||
<span class="task-comment-id">#${escapeHtml(comment.id)}</span>
|
||||
</div>
|
||||
<div class="task-comment-body">${escapeHtml(comment.comment)}</div>
|
||||
<div class="task-comment-action-row">
|
||||
<button class="task-comment-action" type="button" data-comment-reply="${escapeHtml(comment.id)}">Reply</button>
|
||||
${manageActions}
|
||||
</div>
|
||||
<div class="task-comment-reply-slot"></div>
|
||||
<div class="task-comment-edit-slot"></div>
|
||||
`;
|
||||
|
||||
list.appendChild(article);
|
||||
|
||||
const children = commentsByParent.get(Number(comment.id)) ?? [];
|
||||
children.forEach((child) => renderComment(child, depth + 1));
|
||||
};
|
||||
|
||||
const rootComments = commentsByParent.get(null) ?? [];
|
||||
rootComments.forEach((comment) => renderComment(comment));
|
||||
}
|
||||
|
||||
function groupCommentsByParent(comments) {
|
||||
const knownIds = new Set(comments.map((comment) => Number(comment.id)));
|
||||
const groups = new Map([[null, []]]);
|
||||
|
||||
[...comments]
|
||||
.sort((first, second) => Number(first.id) - Number(second.id))
|
||||
.forEach((comment) => {
|
||||
const parentId = comment.response_to && knownIds.has(Number(comment.response_to))
|
||||
? Number(comment.response_to)
|
||||
: null;
|
||||
|
||||
if (!groups.has(parentId)) {
|
||||
groups.set(parentId, []);
|
||||
}
|
||||
|
||||
groups.get(parentId).push(comment);
|
||||
});
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
async function submitTaskComment(comment, responseTo = null) {
|
||||
if (!currentTask) return;
|
||||
|
||||
const result = await apiPost('/api/task.php', {
|
||||
api: 'CreateComment',
|
||||
task_id: currentTask.id
|
||||
}, {
|
||||
comment,
|
||||
response_to: responseTo
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Could not save comment.');
|
||||
}
|
||||
|
||||
await loadTaskComments(currentTask.id);
|
||||
}
|
||||
|
||||
async function updateTaskComment(commentId, comment) {
|
||||
if (!currentTask) return;
|
||||
|
||||
const result = await apiPost('/api/task.php', {
|
||||
api: 'EditComment',
|
||||
task_id: currentTask.id,
|
||||
comment_id: commentId
|
||||
}, {
|
||||
comment
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Could not update comment.');
|
||||
}
|
||||
|
||||
await loadTaskComments(currentTask.id);
|
||||
}
|
||||
|
||||
async function deleteTaskComment(commentId) {
|
||||
if (!currentTask) return;
|
||||
|
||||
const result = await apiPost('/api/task.php', {
|
||||
api: 'DeleteComment',
|
||||
task_id: currentTask.id,
|
||||
comment_id: commentId
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Could not delete comment.');
|
||||
}
|
||||
|
||||
await loadTaskComments(currentTask.id);
|
||||
}
|
||||
|
||||
async function openTaskCreatePopup() {
|
||||
const form = document.getElementById('createTaskForm');
|
||||
|
||||
if (!form) return;
|
||||
|
||||
await populateTaskOptionSelects();
|
||||
await populateTaskProjectSelect();
|
||||
await populateTaskRelationSelects();
|
||||
|
||||
resetTaskPopup();
|
||||
|
||||
openPopup('createTask');
|
||||
}
|
||||
|
||||
async function openTaskEditPopup() {
|
||||
if (!currentTask) return;
|
||||
|
||||
const form = document.getElementById('createTaskForm');
|
||||
|
||||
if (!form) return;
|
||||
|
||||
await populateTaskOptionSelects();
|
||||
await populateTaskProjectSelect(currentTask.project);
|
||||
await populateTaskRelationSelects(currentTask.project, {
|
||||
assignee: currentTask.assignee ?? '',
|
||||
fix_version: currentTask.fix_version ?? ''
|
||||
});
|
||||
|
||||
form.reset();
|
||||
form.dataset.mode = 'edit';
|
||||
document.getElementById('taskPopupTitle').textContent = `Edit ${currentTask.id}`;
|
||||
document.getElementById('taskPopupSubmit').textContent = 'Update Task';
|
||||
document.getElementById('taskFormTaskId').value = currentTask.id;
|
||||
document.getElementById('taskFormProjectId').value = currentTask.project;
|
||||
document.getElementById('taskFormProjectId').disabled = true;
|
||||
document.getElementById('taskFormTitle').value = currentTask.title ?? '';
|
||||
document.getElementById('taskFormDescription').value = currentTask.description ?? '';
|
||||
document.getElementById('createTaskType').value = currentTask.type ?? '';
|
||||
document.getElementById('createTaskPriority').value = currentTask.priority ?? '';
|
||||
document.getElementById('taskFormFixVersion').value = currentTask.fix_version ?? '';
|
||||
document.getElementById('taskFormAssignee').value = currentTask.assignee ?? '';
|
||||
|
||||
openPopup('createTask');
|
||||
}
|
||||
|
||||
function initTaskInlineEditing() {
|
||||
if (!canEditTasks()) return;
|
||||
|
||||
document.querySelectorAll('.task-editable').forEach((element) => {
|
||||
if (element.dataset.inlineReady === 'true') return;
|
||||
|
||||
element.dataset.inlineReady = 'true';
|
||||
element.title = 'Click to edit';
|
||||
|
||||
element.addEventListener('click', () => openTaskInlineEditor(element));
|
||||
element.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
openTaskInlineEditor(element);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function openTaskInlineEditor(element) {
|
||||
if (!canEditTasks() || !currentTask || element.querySelector('.task-inline-form')) return;
|
||||
|
||||
const field = element.dataset.taskField;
|
||||
const currentValue = element.dataset.currentValue ?? '';
|
||||
const originalHtml = element.innerHTML;
|
||||
const originalClasses = Array.from(element.classList);
|
||||
|
||||
element.classList.remove('is-empty');
|
||||
element.innerHTML = '';
|
||||
|
||||
const form = document.createElement('form');
|
||||
form.className = 'task-inline-form';
|
||||
|
||||
const input = await createTaskInlineInput(field, currentValue);
|
||||
form.appendChild(input);
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'task-inline-actions';
|
||||
actions.innerHTML = `
|
||||
<button class="btn btn-sm btn-primary" type="submit">
|
||||
<i class="fa-solid fa-check"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" type="button" data-inline-cancel>
|
||||
<i class="fa-solid fa-xmark"></i>
|
||||
</button>
|
||||
`;
|
||||
|
||||
if (input.tagName !== 'SELECT') {
|
||||
form.appendChild(actions);
|
||||
}
|
||||
|
||||
element.appendChild(form);
|
||||
input.focus();
|
||||
|
||||
if (input.select && input.tagName !== 'SELECT') {
|
||||
input.select();
|
||||
}
|
||||
|
||||
let editorClosed = false;
|
||||
|
||||
const restore = () => {
|
||||
if (editorClosed) return;
|
||||
|
||||
editorClosed = true;
|
||||
element.innerHTML = originalHtml;
|
||||
element.className = originalClasses.join(' ');
|
||||
};
|
||||
|
||||
let saving = false;
|
||||
|
||||
const save = async () => {
|
||||
if (saving) return;
|
||||
|
||||
const nextValue = input.value;
|
||||
|
||||
if (String(nextValue) === String(currentValue)) {
|
||||
restore();
|
||||
return;
|
||||
}
|
||||
|
||||
saving = true;
|
||||
element.classList.add('is-saving');
|
||||
|
||||
const result = await apiPost('/api/task.php', {
|
||||
api: 'Edit',
|
||||
task_id: currentTask.id
|
||||
}, {
|
||||
[field]: nextValue
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
alert(result.error || 'Could not update task.');
|
||||
saving = false;
|
||||
restore();
|
||||
return;
|
||||
}
|
||||
|
||||
editorClosed = true;
|
||||
element.classList.remove('is-saving');
|
||||
currentTask = {
|
||||
...currentTask,
|
||||
[field]: nextValue === '' ? null : nextValue
|
||||
};
|
||||
|
||||
if (field === 'type') {
|
||||
await loadTask(currentTask.id, false);
|
||||
return;
|
||||
}
|
||||
|
||||
await renderTask(currentTask);
|
||||
};
|
||||
|
||||
form.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
await save();
|
||||
});
|
||||
|
||||
form.querySelector('[data-inline-cancel]')?.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
restore();
|
||||
});
|
||||
|
||||
input.addEventListener('keydown', async (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
restore();
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' && input.tagName === 'TEXTAREA' && (event.ctrlKey || event.metaKey)) {
|
||||
event.preventDefault();
|
||||
await save();
|
||||
}
|
||||
});
|
||||
|
||||
if (input.tagName === 'SELECT') {
|
||||
input.addEventListener('change', save);
|
||||
input.addEventListener('blur', () => {
|
||||
window.setTimeout(() => {
|
||||
if (editorClosed) return;
|
||||
if (element.classList.contains('is-saving')) return;
|
||||
if (element.contains(document.activeElement)) return;
|
||||
restore();
|
||||
}, 120);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function createTaskInlineInput(field, currentValue) {
|
||||
if (field === 'description') {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.className = 'form-control task-inline-input';
|
||||
textarea.rows = 5;
|
||||
textarea.value = currentValue;
|
||||
return textarea;
|
||||
}
|
||||
|
||||
if (field === 'type') {
|
||||
const select = document.createElement('select');
|
||||
select.className = 'form-select task-inline-select';
|
||||
populateSelect(select, await getTaskTypes(), {
|
||||
includeEmpty: false,
|
||||
selectedValue: currentValue
|
||||
});
|
||||
return select;
|
||||
}
|
||||
|
||||
if (field === 'priority') {
|
||||
const select = document.createElement('select');
|
||||
select.className = 'form-select task-inline-select';
|
||||
populateSelect(select, await getTaskPriorities(), {
|
||||
includeEmpty: false,
|
||||
selectedValue: currentValue
|
||||
});
|
||||
return select;
|
||||
}
|
||||
|
||||
if (field === 'fix_version') {
|
||||
const select = document.createElement('select');
|
||||
select.className = 'form-select task-inline-select';
|
||||
populateSelect(select, await getProjectVersions(currentTask.project), {
|
||||
placeholder: 'No fix version',
|
||||
selectedValue: currentValue
|
||||
});
|
||||
return select;
|
||||
}
|
||||
|
||||
if (field === 'assignee') {
|
||||
const select = document.createElement('select');
|
||||
select.className = 'form-select task-inline-select';
|
||||
populateSelect(select, await getUsers(), {
|
||||
placeholder: 'Unassigned',
|
||||
selectedValue: currentValue
|
||||
});
|
||||
return select;
|
||||
}
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.className = 'form-control task-inline-input';
|
||||
input.type = 'text';
|
||||
input.value = currentValue;
|
||||
input.required = field === 'title';
|
||||
return input;
|
||||
}
|
||||
|
||||
function createCustomFieldInput(type, currentValue) {
|
||||
if (type === 'boolean') {
|
||||
const select = document.createElement('select');
|
||||
select.className = 'form-select task-inline-select';
|
||||
[
|
||||
{ id: '', name: 'No value' },
|
||||
{ id: 'true', name: 'True' },
|
||||
{ id: 'false', name: 'False' }
|
||||
].forEach((item) => {
|
||||
const option = document.createElement('option');
|
||||
option.value = item.id;
|
||||
option.textContent = item.name;
|
||||
option.selected = String(item.id) === String(currentValue);
|
||||
select.appendChild(option);
|
||||
});
|
||||
return select;
|
||||
}
|
||||
|
||||
if (type === 'text' || type === 'json') {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.className = 'form-control task-inline-input';
|
||||
textarea.rows = type === 'json' ? 4 : 3;
|
||||
textarea.value = currentValue;
|
||||
return textarea;
|
||||
}
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.className = 'form-control task-inline-input';
|
||||
input.type = type === 'date' ? 'date' : 'text';
|
||||
input.inputMode = ['int', 'float'].includes(type) ? 'decimal' : '';
|
||||
input.value = currentValue;
|
||||
return input;
|
||||
}
|
||||
|
||||
async function openCustomFieldInlineEditor(element) {
|
||||
if (!canEditTasks() || !currentTask || element.querySelector('.task-inline-form')) return;
|
||||
|
||||
const fieldId = element.dataset.customFieldId;
|
||||
const fieldType = element.dataset.customFieldType ?? 'string';
|
||||
const currentValue = element.dataset.currentValue ?? '';
|
||||
const originalHtml = element.innerHTML;
|
||||
const originalClasses = Array.from(element.classList);
|
||||
|
||||
element.classList.remove('is-empty');
|
||||
element.innerHTML = '';
|
||||
|
||||
const form = document.createElement('form');
|
||||
form.className = 'task-inline-form';
|
||||
|
||||
const input = createCustomFieldInput(fieldType, currentValue);
|
||||
form.appendChild(input);
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'task-inline-actions';
|
||||
actions.innerHTML = `
|
||||
<button class="btn btn-sm btn-primary" type="submit">
|
||||
<i class="fa-solid fa-check"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" type="button" data-inline-cancel>
|
||||
<i class="fa-solid fa-xmark"></i>
|
||||
</button>
|
||||
`;
|
||||
form.appendChild(actions);
|
||||
|
||||
element.appendChild(form);
|
||||
input.focus();
|
||||
|
||||
if (input.select && input.tagName !== 'SELECT') {
|
||||
input.select();
|
||||
}
|
||||
|
||||
let closed = false;
|
||||
const restore = () => {
|
||||
if (closed) return;
|
||||
|
||||
closed = true;
|
||||
element.innerHTML = originalHtml;
|
||||
element.className = originalClasses.join(' ');
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
const nextValue = input.value;
|
||||
|
||||
if (String(nextValue) === String(currentValue)) {
|
||||
restore();
|
||||
return;
|
||||
}
|
||||
|
||||
element.classList.add('is-saving');
|
||||
|
||||
const result = await apiPost('/api/task.php', {
|
||||
api: 'SetCustomFieldValue',
|
||||
task_id: currentTask.id
|
||||
}, {
|
||||
field_id: fieldId,
|
||||
value: nextValue
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
alert(result.error || 'Could not update custom field.');
|
||||
restore();
|
||||
return;
|
||||
}
|
||||
|
||||
closed = true;
|
||||
await loadTask(currentTask.id, false);
|
||||
};
|
||||
|
||||
form.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
await save();
|
||||
});
|
||||
|
||||
form.querySelector('[data-inline-cancel]')?.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
restore();
|
||||
});
|
||||
|
||||
input.addEventListener('keydown', async (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
restore();
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' && input.tagName !== 'TEXTAREA') {
|
||||
event.preventDefault();
|
||||
await save();
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' && input.tagName === 'TEXTAREA' && (event.ctrlKey || event.metaKey)) {
|
||||
event.preventDefault();
|
||||
await save();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function transitionCurrentTask(transitionId) {
|
||||
if (!currentTask || !transitionId) return;
|
||||
|
||||
const result = await apiPost('/api/task.php', {
|
||||
api: 'TransitionStatus',
|
||||
task_id: currentTask.id
|
||||
}, {
|
||||
transition_id: transitionId
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
alert(result.error || 'Could not transition task.');
|
||||
return;
|
||||
}
|
||||
|
||||
await loadTask(currentTask.id, false);
|
||||
}
|
||||
|
||||
181
ProjectKiln/app/js/home/task_list.js
Normal file
181
ProjectKiln/app/js/home/task_list.js
Normal file
@@ -0,0 +1,181 @@
|
||||
async function loadProjectTasks(projectId, page = 1, pushHistory = true) {
|
||||
const view = showView('taskListView');
|
||||
|
||||
if (!view) return;
|
||||
|
||||
if (pushHistory) {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('page', 'home');
|
||||
url.searchParams.set('project', projectId);
|
||||
url.searchParams.set('tasks', '1');
|
||||
url.searchParams.delete('task');
|
||||
url.searchParams.delete('version');
|
||||
url.searchParams.delete('profile');
|
||||
url.searchParams.delete('admin');
|
||||
window.history.pushState({}, '', url);
|
||||
}
|
||||
|
||||
currentProjectTaskProject = projectId;
|
||||
setText('taskListProjectKey', projectId);
|
||||
setText('taskListTitle', `${projectId} Tasks`);
|
||||
|
||||
const container = document.getElementById('taskListContainer');
|
||||
const empty = document.getElementById('taskListEmpty');
|
||||
|
||||
container.innerHTML = '<div class="version-task-loading">Loading tasks...</div>';
|
||||
empty.hidden = true;
|
||||
currentProjectTasks = [];
|
||||
projectTaskPagination = null;
|
||||
|
||||
const result = await apiGet('/api/task.php', {
|
||||
api: 'ListTasksByProject',
|
||||
project_id: projectId,
|
||||
page,
|
||||
per_page: taskTablePageSize,
|
||||
sort: projectTaskSort.field,
|
||||
direction: projectTaskSort.direction
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
container.innerHTML = '<div class="alert alert-danger mb-0">Could not load tasks.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.tasks.length) {
|
||||
container.innerHTML = '';
|
||||
empty.hidden = false;
|
||||
renderTaskTablePagination('project');
|
||||
return;
|
||||
}
|
||||
|
||||
currentProjectTasks = await normalizeTaskTableRows(result.tasks);
|
||||
projectTaskPagination = result.pagination ?? null;
|
||||
renderTaskTable('project');
|
||||
}
|
||||
|
||||
async function normalizeTaskTableRows(tasks) {
|
||||
const [types, priorities] = await Promise.all([
|
||||
getTaskTypes(),
|
||||
getTaskPriorities()
|
||||
]);
|
||||
|
||||
return tasks.map((task) => {
|
||||
const normalizedTask = typeof task === 'string'
|
||||
? { id: task, title: '', type: null, priority: null }
|
||||
: task;
|
||||
|
||||
const type = types.find((item) => String(item.id) === String(normalizedTask.type)) ?? null;
|
||||
const priority = priorities.find((item) => String(item.id) === String(normalizedTask.priority)) ?? null;
|
||||
|
||||
return {
|
||||
...normalizedTask,
|
||||
typeOption: type,
|
||||
priorityOption: priority,
|
||||
typeName: type?.name ?? '',
|
||||
priorityName: priority?.name ?? '',
|
||||
statusName: normalizedTask.status_state?.name ?? '',
|
||||
statusColor: normalizedTask.status_state?.color ?? '',
|
||||
assigneeUser: normalizedTask.assignee
|
||||
? {
|
||||
id: normalizedTask.assignee,
|
||||
name: normalizedTask.assignee_name ?? `User ${normalizedTask.assignee}`,
|
||||
picture: normalizedTask.assignee_picture ?? null
|
||||
}
|
||||
: null,
|
||||
assigneeName: normalizedTask.assignee_name ?? ''
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function renderTaskTable(kind) {
|
||||
const isVersion = kind === 'version';
|
||||
const list = document.getElementById(isVersion ? 'versionTaskList' : 'taskListContainer');
|
||||
const empty = document.getElementById(isVersion ? 'versionTaskEmpty' : 'taskListEmpty');
|
||||
const tasks = isVersion ? currentVersionTasks : currentProjectTasks;
|
||||
|
||||
if (!list || !empty) return;
|
||||
|
||||
updateTaskTableSortButtons(kind);
|
||||
|
||||
list.innerHTML = '';
|
||||
empty.hidden = tasks.length > 0;
|
||||
|
||||
tasks.forEach((task) => {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'version-task-row version-task-item';
|
||||
button.innerHTML = `
|
||||
<span class="version-task-id">${escapeHtml(task.id)}</span>
|
||||
<span class="version-task-title">${escapeHtml(task.title || '-')}</span>
|
||||
<span>${renderMetaBadge(task.typeOption)}</span>
|
||||
<span>${renderMetaBadge(task.priorityOption)}</span>
|
||||
<span>${renderUser(task.assigneeUser)}</span>
|
||||
<span>${renderTaskTableStatus(task)}</span>
|
||||
`;
|
||||
button.addEventListener('click', () => loadTask(task.id));
|
||||
|
||||
list.appendChild(button);
|
||||
});
|
||||
|
||||
renderTaskTablePagination(kind);
|
||||
}
|
||||
|
||||
function renderTaskTableStatus(task) {
|
||||
if (!task.statusName) return '-';
|
||||
|
||||
const color = task.statusColor || '#6c757d';
|
||||
|
||||
return `
|
||||
<span class="version-task-status" style="--task-status-color: ${escapeHtml(color)}">
|
||||
${escapeHtml(task.statusName)}
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderTaskTablePagination(kind) {
|
||||
const isVersion = kind === 'version';
|
||||
const pagination = isVersion ? versionTaskPagination : projectTaskPagination;
|
||||
const container = document.getElementById(isVersion ? 'versionTaskPagination' : 'projectTaskPagination');
|
||||
|
||||
if (!container) return;
|
||||
|
||||
if (!pagination || pagination.total <= pagination.per_page) {
|
||||
container.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const start = ((pagination.page - 1) * pagination.per_page) + 1;
|
||||
const end = Math.min(pagination.total, pagination.page * pagination.per_page);
|
||||
|
||||
container.innerHTML = `
|
||||
<span>${escapeHtml(start)}-${escapeHtml(end)} of ${escapeHtml(pagination.total)}</span>
|
||||
<div class="version-task-page-actions">
|
||||
<button class="btn btn-sm btn-outline-secondary" type="button" data-task-page="${escapeHtml(kind)}" data-page="${escapeHtml(pagination.page - 1)}" ${pagination.page <= 1 ? 'disabled' : ''}>
|
||||
<i class="fa-solid fa-chevron-left"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" type="button" data-task-page="${escapeHtml(kind)}" data-page="${escapeHtml(pagination.page + 1)}" ${pagination.page >= pagination.total_pages ? 'disabled' : ''}>
|
||||
<i class="fa-solid fa-chevron-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function updateTaskTableSortButtons(kind) {
|
||||
const isVersion = kind === 'version';
|
||||
const selector = isVersion ? '[data-version-task-sort]' : '[data-project-task-sort]';
|
||||
const state = isVersion ? versionTaskSort : projectTaskSort;
|
||||
|
||||
document.querySelectorAll(selector).forEach((button) => {
|
||||
const field = isVersion ? button.dataset.versionTaskSort : button.dataset.projectTaskSort;
|
||||
const isActive = field === state.field;
|
||||
const icon = button.querySelector('i');
|
||||
|
||||
button.classList.toggle('is-active', isActive);
|
||||
|
||||
if (!icon) return;
|
||||
|
||||
icon.className = isActive
|
||||
? `fa-solid ${state.direction === 'asc' ? 'fa-sort-up' : 'fa-sort-down'}`
|
||||
: 'fa-solid fa-sort';
|
||||
});
|
||||
}
|
||||
258
ProjectKiln/app/js/home/version.js
Normal file
258
ProjectKiln/app/js/home/version.js
Normal file
@@ -0,0 +1,258 @@
|
||||
function openVersionCreatePopup(projectId) {
|
||||
resetVersionPopup();
|
||||
document.getElementById('versionFormProjectId').value = projectId;
|
||||
openPopup('createVersion');
|
||||
}
|
||||
|
||||
function openVersionEditPopup() {
|
||||
if (!currentVersion) return;
|
||||
|
||||
resetVersionPopup();
|
||||
|
||||
const form = document.getElementById('createVersionForm');
|
||||
|
||||
if (!form) return;
|
||||
|
||||
form.dataset.mode = 'edit';
|
||||
document.getElementById('versionPopupTitle').textContent = `Edit ${currentVersion.name}`;
|
||||
document.getElementById('versionPopupSubmit').textContent = 'Update Version';
|
||||
document.getElementById('versionFormVersionId').value = currentVersion.id;
|
||||
document.getElementById('versionFormProjectId').value = currentVersion.project;
|
||||
document.getElementById('versionFormName').value = currentVersion.name ?? '';
|
||||
document.getElementById('versionFormDescription').value = currentVersion.description ?? '';
|
||||
document.getElementById('versionFormDueDate').value = currentVersion.due_date ?? '';
|
||||
document.getElementById('versionFormReleasedDate').value = currentVersion.released_date ?? '';
|
||||
|
||||
openPopup('createVersion');
|
||||
}
|
||||
|
||||
async function loadVersion(versionId, pushHistory = true) {
|
||||
const view = showView('versionView');
|
||||
|
||||
if (!view) return;
|
||||
|
||||
if (pushHistory) {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('page', 'home');
|
||||
url.searchParams.set('version', versionId);
|
||||
url.searchParams.delete('task');
|
||||
url.searchParams.delete('project');
|
||||
url.searchParams.delete('tasks');
|
||||
url.searchParams.delete('profile');
|
||||
url.searchParams.delete('admin');
|
||||
window.history.pushState({}, '', url);
|
||||
}
|
||||
|
||||
const result = await apiGet('/api/version.php', {
|
||||
api: 'VersionInfo',
|
||||
version_id: versionId
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
setText('versionName', 'Version not found');
|
||||
setText('versionDescription', '');
|
||||
return;
|
||||
}
|
||||
|
||||
await renderVersion(result.version);
|
||||
await loadVersionTasks(versionId);
|
||||
await ensureProjectOpen(result.version.project);
|
||||
}
|
||||
|
||||
async function renderVersion(version) {
|
||||
currentVersion = version;
|
||||
|
||||
setVersionEditableText('versionKey', `Version ${version.id}`);
|
||||
setVersionEditableText('versionName', version.name, version.name);
|
||||
setVersionEditableText('versionDescription', version.description, version.description, 'No description provided.');
|
||||
setText('versionProject', version.project);
|
||||
setText('versionCreated', version.created_date || '-');
|
||||
setVersionEditableText('versionDueDate', version.due_date, version.due_date, 'No due date');
|
||||
setVersionEditableText('versionReleasedDate', version.released_date, version.released_date, 'Not released');
|
||||
}
|
||||
|
||||
async function loadVersionTasks(versionId, page = 1) {
|
||||
const list = document.getElementById('versionTaskList');
|
||||
const empty = document.getElementById('versionTaskEmpty');
|
||||
|
||||
if (!list || !empty) return;
|
||||
|
||||
list.innerHTML = '<div class="version-task-loading">Loading tasks...</div>';
|
||||
empty.hidden = true;
|
||||
currentVersionTasks = [];
|
||||
versionTaskPagination = null;
|
||||
|
||||
const result = await apiGet('/api/task.php', {
|
||||
api: 'ListTasksByVersion',
|
||||
version_id: versionId,
|
||||
page,
|
||||
per_page: taskTablePageSize,
|
||||
sort: versionTaskSort.field,
|
||||
direction: versionTaskSort.direction
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
list.innerHTML = '<div class="alert alert-danger mb-0">Could not load tasks.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.tasks.length) {
|
||||
list.innerHTML = '';
|
||||
empty.hidden = false;
|
||||
renderTaskTablePagination('version');
|
||||
return;
|
||||
}
|
||||
|
||||
currentVersionTasks = await normalizeTaskTableRows(result.tasks);
|
||||
versionTaskPagination = result.pagination ?? null;
|
||||
renderTaskTable('version');
|
||||
}
|
||||
|
||||
function initVersionInlineEditing() {
|
||||
if (!canEditVersions()) return;
|
||||
|
||||
document.querySelectorAll('.version-editable').forEach((element) => {
|
||||
if (element.dataset.inlineReady === 'true') return;
|
||||
|
||||
element.dataset.inlineReady = 'true';
|
||||
element.title = 'Click to edit';
|
||||
|
||||
element.addEventListener('click', () => openVersionInlineEditor(element));
|
||||
element.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
openVersionInlineEditor(element);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function openVersionInlineEditor(element) {
|
||||
if (!canEditVersions() || !currentVersion || element.querySelector('.version-inline-form')) return;
|
||||
|
||||
const field = element.dataset.versionField;
|
||||
const currentValue = element.dataset.currentValue ?? '';
|
||||
const originalHtml = element.innerHTML;
|
||||
const originalClasses = Array.from(element.classList);
|
||||
|
||||
element.classList.remove('is-empty');
|
||||
element.innerHTML = '';
|
||||
|
||||
const form = document.createElement('form');
|
||||
form.className = 'version-inline-form';
|
||||
|
||||
const input = createVersionInlineInput(field, currentValue);
|
||||
form.appendChild(input);
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'version-inline-actions';
|
||||
actions.innerHTML = `
|
||||
<button class="btn btn-sm btn-primary" type="submit">
|
||||
<i class="fa-solid fa-check"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" type="button" data-version-inline-cancel>
|
||||
<i class="fa-solid fa-xmark"></i>
|
||||
</button>
|
||||
`;
|
||||
form.appendChild(actions);
|
||||
|
||||
element.appendChild(form);
|
||||
input.focus();
|
||||
|
||||
if (input.select && input.type !== 'date') {
|
||||
input.select();
|
||||
}
|
||||
|
||||
let editorClosed = false;
|
||||
|
||||
const restore = () => {
|
||||
if (editorClosed) return;
|
||||
|
||||
editorClosed = true;
|
||||
element.innerHTML = originalHtml;
|
||||
element.className = originalClasses.join(' ');
|
||||
};
|
||||
|
||||
let saving = false;
|
||||
|
||||
const save = async () => {
|
||||
if (saving) return;
|
||||
|
||||
const nextValue = input.value;
|
||||
|
||||
if (String(nextValue) === String(currentValue)) {
|
||||
restore();
|
||||
return;
|
||||
}
|
||||
|
||||
saving = true;
|
||||
element.classList.add('is-saving');
|
||||
|
||||
const result = await apiPost('/api/version.php', {
|
||||
api: 'Edit',
|
||||
version_id: currentVersion.id
|
||||
}, {
|
||||
[field]: nextValue
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
alert(result.error || 'Could not update version.');
|
||||
saving = false;
|
||||
restore();
|
||||
return;
|
||||
}
|
||||
|
||||
editorClosed = true;
|
||||
element.classList.remove('is-saving');
|
||||
currentVersion = {
|
||||
...currentVersion,
|
||||
[field]: nextValue === '' ? null : nextValue
|
||||
};
|
||||
|
||||
taskLookupCache.versionsByProject.delete(currentVersion.project);
|
||||
await renderVersion(currentVersion);
|
||||
projectTreePromise = loadProjectTree();
|
||||
await projectTreePromise;
|
||||
};
|
||||
|
||||
form.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
await save();
|
||||
});
|
||||
|
||||
form.querySelector('[data-version-inline-cancel]')?.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
restore();
|
||||
});
|
||||
|
||||
input.addEventListener('keydown', async (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
restore();
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' && input.tagName === 'TEXTAREA' && (event.ctrlKey || event.metaKey)) {
|
||||
event.preventDefault();
|
||||
await save();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function createVersionInlineInput(field, currentValue) {
|
||||
if (field === 'description') {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.className = 'form-control version-inline-input';
|
||||
textarea.rows = 5;
|
||||
textarea.value = currentValue;
|
||||
return textarea;
|
||||
}
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.className = 'form-control version-inline-input';
|
||||
input.type = field === 'due_date' || field === 'released_date' ? 'date' : 'text';
|
||||
input.value = currentValue;
|
||||
input.required = field === 'name';
|
||||
return input;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,30 @@ const THEME_STYLES = [
|
||||
'beige' => 'app/css/beige_mode.css',
|
||||
];
|
||||
|
||||
function projectKilnTableExists(string $table): bool
|
||||
{
|
||||
$stmt = db()->prepare(
|
||||
'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?'
|
||||
);
|
||||
$stmt->execute([$table]);
|
||||
|
||||
return (int)$stmt->fetchColumn() > 0;
|
||||
}
|
||||
|
||||
function projectKilnIsInstalled(): bool
|
||||
{
|
||||
if (!projectKilnTableExists('settings')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$stmt = db()->prepare(
|
||||
"SELECT COUNT(*) FROM settings WHERE setting_name = 'installed' AND LOWER(setting_value) = 'true'"
|
||||
);
|
||||
$stmt->execute();
|
||||
|
||||
return (int)$stmt->fetchColumn() > 0;
|
||||
}
|
||||
|
||||
function currentTheme(): string
|
||||
{
|
||||
$theme = 'dark';
|
||||
@@ -43,8 +67,17 @@ function currentTheme(): string
|
||||
return array_key_exists($storedTheme, THEME_STYLES) ? $storedTheme : $theme;
|
||||
}
|
||||
|
||||
$isInstalled = projectKilnIsInstalled();
|
||||
$theme = $isInstalled ? currentTheme() : 'dark';
|
||||
|
||||
if (!$isInstalled) {
|
||||
define('PROJECTKILN_INSTALL_EMBEDDED', true);
|
||||
|
||||
ob_start();
|
||||
require __DIR__ . '/install/install.php';
|
||||
$pageContent = ob_get_clean();
|
||||
} else {
|
||||
$page = $_GET['page'] ?? 'home';
|
||||
$theme = currentTheme();
|
||||
|
||||
$routes = [
|
||||
'home' => 'home.php',
|
||||
@@ -61,6 +94,7 @@ if (!isset($routes[$page])) {
|
||||
ob_start();
|
||||
require $pageFile;
|
||||
$pageContent = ob_get_clean();
|
||||
}
|
||||
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
|
||||
251
ProjectKiln/install/install.php
Normal file
251
ProjectKiln/install/install.php
Normal file
@@ -0,0 +1,251 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../db.php';
|
||||
|
||||
function installerEmbedded(): bool {
|
||||
return defined('PROJECTKILN_INSTALL_EMBEDDED') && PROJECTKILN_INSTALL_EMBEDDED === true;
|
||||
}
|
||||
|
||||
function installerTableExists(PDO $pdo, string $table): bool {
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?'
|
||||
);
|
||||
$stmt->execute([$table]);
|
||||
|
||||
return (int)$stmt->fetchColumn() > 0;
|
||||
}
|
||||
|
||||
function installerIsInstalled(PDO $pdo): bool {
|
||||
if (!installerTableExists($pdo, 'settings')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT COUNT(*) FROM settings WHERE setting_name = 'installed' AND LOWER(setting_value) = 'true'"
|
||||
);
|
||||
$stmt->execute();
|
||||
|
||||
return (int)$stmt->fetchColumn() > 0;
|
||||
}
|
||||
|
||||
function redirectHome(): never {
|
||||
header('Location: ' . (installerEmbedded() ? '?page=home' : '../?page=home'));
|
||||
exit;
|
||||
}
|
||||
|
||||
if (installerEmbedded()) {
|
||||
$pageStyles[] = 'app/css/login.css';
|
||||
}
|
||||
|
||||
$pdo = db();
|
||||
|
||||
if (installerIsInstalled($pdo)) {
|
||||
redirectHome();
|
||||
}
|
||||
|
||||
$installError = null;
|
||||
$formError = null;
|
||||
|
||||
try {
|
||||
require __DIR__ . '/install_db.php';
|
||||
require_once __DIR__ . '/../auth.php';
|
||||
} catch (Throwable $exception) {
|
||||
$installError = $exception->getMessage();
|
||||
}
|
||||
|
||||
if ($installError === null && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$username = trim((string)($_POST['username'] ?? ''));
|
||||
$email = strtolower(trim((string)($_POST['email'] ?? '')));
|
||||
$password = (string)($_POST['password'] ?? '');
|
||||
$passwordConfirm = (string)($_POST['password_confirm'] ?? '');
|
||||
|
||||
if ($username === '' || $email === '' || $password === '') {
|
||||
$formError = 'Please fill in all required fields.';
|
||||
} elseif (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
$formError = 'Please enter a valid email address.';
|
||||
} elseif (strlen($password) < 8) {
|
||||
$formError = 'Password must be at least 8 characters long.';
|
||||
} elseif ($password !== $passwordConfirm) {
|
||||
$formError = 'Passwords do not match.';
|
||||
} else {
|
||||
$userId = register($username, $email, $password);
|
||||
|
||||
if ($userId === null) {
|
||||
$formError = 'Admin user could not be created. Check if the email is already used.';
|
||||
} else {
|
||||
$adminRightStmt = $pdo->prepare("SELECT id FROM rights WHERE name = 'Admin' LIMIT 1");
|
||||
$adminRightStmt->execute();
|
||||
$adminRightId = (int)$adminRightStmt->fetchColumn();
|
||||
|
||||
if ($adminRightId <= 0) {
|
||||
$formError = 'Admin right is missing after database setup.';
|
||||
} else {
|
||||
$existingRight = $pdo->prepare(
|
||||
'SELECT COUNT(*) FROM user_rights WHERE user_id = ? AND right_id = ?'
|
||||
);
|
||||
$existingRight->execute([$userId, $adminRightId]);
|
||||
|
||||
if ((int)$existingRight->fetchColumn() === 0) {
|
||||
$assignAdmin = $pdo->prepare(
|
||||
'INSERT INTO user_rights (user_id, right_id) VALUES (?, ?)'
|
||||
);
|
||||
$assignAdmin->execute([$userId, $adminRightId]);
|
||||
}
|
||||
|
||||
$markInstalled = $pdo->prepare(
|
||||
"INSERT INTO settings (id, setting_name, setting_value)
|
||||
VALUES (2, 'installed', 'true')
|
||||
ON DUPLICATE KEY UPDATE setting_name = VALUES(setting_name), setting_value = VALUES(setting_value)"
|
||||
);
|
||||
$markInstalled->execute();
|
||||
|
||||
login($email, $password, false);
|
||||
redirectHome();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function oldInput(string $name): string {
|
||||
return htmlspecialchars((string)($_POST[$name] ?? ''), ENT_QUOTES, 'UTF-8');
|
||||
}
|
||||
?>
|
||||
<?php if (!installerEmbedded()): ?>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>ProjectKiln Install</title>
|
||||
<link rel="stylesheet" href="../app/bootstrap/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="../app/fontawesome/css/all.min.css">
|
||||
<link rel="stylesheet" href="../app/css/main.css">
|
||||
<link rel="stylesheet" href="../app/css/dark_mode.css">
|
||||
<link rel="stylesheet" href="../app/css/login.css">
|
||||
</head>
|
||||
<body>
|
||||
<?php endif; ?>
|
||||
<main class="container auth-container d-flex align-items-center justify-content-center">
|
||||
<div class="card auth-card shadow-lg border">
|
||||
<div class="row g-0 h-100">
|
||||
<div class="col-lg-6 auth-brand p-5 d-flex flex-column justify-content-center">
|
||||
<div class="auth-logo mb-4">
|
||||
<i class="fa-solid fa-fire-flame-simple"></i>
|
||||
</div>
|
||||
|
||||
<h1 class="display-5 fw-bold mb-3">
|
||||
ProjectKiln Setup
|
||||
</h1>
|
||||
|
||||
<p class="text-secondary mb-0">
|
||||
Create the first administrator account to finish the installation.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-6 p-5 d-flex flex-column justify-content-center">
|
||||
<?php if ($installError !== null): ?>
|
||||
<div class="alert alert-danger mb-4">
|
||||
<i class="fa-solid fa-circle-exclamation me-2"></i>
|
||||
Database setup failed: <?= htmlspecialchars($installError, ENT_QUOTES, 'UTF-8') ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($formError !== null): ?>
|
||||
<div class="alert alert-danger mb-4">
|
||||
<i class="fa-solid fa-circle-exclamation me-2"></i>
|
||||
<?= htmlspecialchars($formError, ENT_QUOTES, 'UTF-8') ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="post">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Username</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">
|
||||
<i class="fa-solid fa-user"></i>
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
name="username"
|
||||
class="form-control"
|
||||
value="<?= oldInput('username') ?>"
|
||||
autocomplete="username"
|
||||
required
|
||||
<?= $installError !== null ? 'disabled' : '' ?>
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Email Address</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">
|
||||
<i class="fa-solid fa-envelope"></i>
|
||||
</span>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
class="form-control"
|
||||
value="<?= oldInput('email') ?>"
|
||||
autocomplete="email"
|
||||
required
|
||||
<?= $installError !== null ? 'disabled' : '' ?>
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Password</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">
|
||||
<i class="fa-solid fa-lock"></i>
|
||||
</span>
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
class="form-control"
|
||||
autocomplete="new-password"
|
||||
minlength="8"
|
||||
required
|
||||
<?= $installError !== null ? 'disabled' : '' ?>
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="form-label">Confirm Password</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">
|
||||
<i class="fa-solid fa-key"></i>
|
||||
</span>
|
||||
<input
|
||||
type="password"
|
||||
name="password_confirm"
|
||||
class="form-control"
|
||||
autocomplete="new-password"
|
||||
minlength="8"
|
||||
required
|
||||
<?= $installError !== null ? 'disabled' : '' ?>
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary w-100 auth-submit"
|
||||
<?= $installError !== null ? 'disabled' : '' ?>
|
||||
>
|
||||
<i class="fa-solid fa-user-shield me-2"></i>
|
||||
Create Admin
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<?php if (!installerEmbedded()): ?>
|
||||
</body>
|
||||
</html>
|
||||
<?php endif; ?>
|
||||
495
ProjectKiln/install/install_db.php
Normal file
495
ProjectKiln/install/install_db.php
Normal file
@@ -0,0 +1,495 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../db.php';
|
||||
|
||||
$pdo = db();
|
||||
|
||||
function quoteIdentifier(string $identifier): string {
|
||||
if (!preg_match('/^[A-Za-z0-9_]+$/', $identifier)) {
|
||||
throw new InvalidArgumentException('Invalid identifier: ' . $identifier);
|
||||
}
|
||||
|
||||
return '`' . $identifier . '`';
|
||||
}
|
||||
|
||||
function tableExists(PDO $pdo, string $table): bool {
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?'
|
||||
);
|
||||
$stmt->execute([$table]);
|
||||
|
||||
return (int) $stmt->fetchColumn() > 0;
|
||||
}
|
||||
|
||||
function indexExists(PDO $pdo, string $table, string $index): bool {
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND INDEX_NAME = ?'
|
||||
);
|
||||
$stmt->execute([$table, $index]);
|
||||
|
||||
return (int) $stmt->fetchColumn() > 0;
|
||||
}
|
||||
|
||||
function foreignKeyExists(PDO $pdo, string $constraint): bool {
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS
|
||||
WHERE CONSTRAINT_SCHEMA = DATABASE()
|
||||
AND CONSTRAINT_NAME = ?
|
||||
AND CONSTRAINT_TYPE = 'FOREIGN KEY'"
|
||||
);
|
||||
$stmt->execute([$constraint]);
|
||||
|
||||
return (int) $stmt->fetchColumn() > 0;
|
||||
}
|
||||
|
||||
function addIndexIfMissing(PDO $pdo, string $table, string $index, string $definition): void {
|
||||
if (indexExists($pdo, $table, $index)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$pdo->exec('ALTER TABLE ' . quoteIdentifier($table) . ' ADD ' . $definition);
|
||||
}
|
||||
|
||||
function addForeignKeyIfMissing(PDO $pdo, string $constraint, string $definition): void {
|
||||
if (foreignKeyExists($pdo, $constraint)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$pdo->exec($definition);
|
||||
}
|
||||
|
||||
function assetBlob(string $relativePath): ?string {
|
||||
$path = __DIR__ . '/../../raw-resources/' . ltrim($relativePath, '/');
|
||||
|
||||
if (!is_file($path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return file_get_contents($path) ?: null;
|
||||
}
|
||||
|
||||
function upsert(PDO $pdo, string $sql, array $params): void {
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
}
|
||||
|
||||
try {
|
||||
$pdo->exec('SET FOREIGN_KEY_CHECKS = 0');
|
||||
|
||||
$pdo->exec(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS `task_states` (
|
||||
`id` int NOT NULL AUTO_INCREMENT,
|
||||
`name` varchar(128) COLLATE utf8mb4_0900_bin NOT NULL,
|
||||
`color` varchar(7) COLLATE utf8mb4_0900_bin NOT NULL,
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_bin
|
||||
SQL);
|
||||
|
||||
$pdo->exec(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS `task_priorities` (
|
||||
`id` int NOT NULL AUTO_INCREMENT,
|
||||
`name` varchar(20) COLLATE utf8mb4_0900_bin NOT NULL,
|
||||
`logo` blob,
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_bin
|
||||
SQL);
|
||||
|
||||
$pdo->exec(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS `task_types` (
|
||||
`id` int NOT NULL AUTO_INCREMENT,
|
||||
`name` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin NOT NULL,
|
||||
`logo` blob,
|
||||
`default_state` int DEFAULT NULL,
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_bin
|
||||
SQL);
|
||||
|
||||
$pdo->exec(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS `users` (
|
||||
`id` int NOT NULL AUTO_INCREMENT,
|
||||
`name` varchar(128) COLLATE utf8mb4_0900_bin NOT NULL,
|
||||
`email` varchar(128) COLLATE utf8mb4_0900_bin NOT NULL,
|
||||
`passwd` varchar(255) COLLATE utf8mb4_0900_bin NOT NULL,
|
||||
`picture` mediumblob,
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_bin
|
||||
SQL);
|
||||
|
||||
$pdo->exec(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS `projects` (
|
||||
`id` varchar(12) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin NOT NULL,
|
||||
`name` varchar(128) COLLATE utf8mb4_0900_bin NOT NULL,
|
||||
`owner` int NOT NULL,
|
||||
`created_date` date NOT NULL,
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_bin
|
||||
SQL);
|
||||
|
||||
$pdo->exec(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS `versions` (
|
||||
`id` int NOT NULL AUTO_INCREMENT,
|
||||
`name` varchar(128) COLLATE utf8mb4_0900_bin NOT NULL,
|
||||
`description` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin,
|
||||
`created_date` date NOT NULL,
|
||||
`due_date` date DEFAULT NULL,
|
||||
`released_date` date DEFAULT NULL,
|
||||
`project` varchar(12) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin NOT NULL,
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_bin
|
||||
SQL);
|
||||
|
||||
$pdo->exec(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS `tasks` (
|
||||
`id` varchar(26) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin NOT NULL,
|
||||
`title` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin NOT NULL,
|
||||
`description` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin,
|
||||
`project` varchar(12) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin NOT NULL,
|
||||
`created_date` date NOT NULL,
|
||||
`last_changed` date NOT NULL,
|
||||
`reporter` int NOT NULL,
|
||||
`assignee` int DEFAULT NULL,
|
||||
`fix_version` int DEFAULT NULL,
|
||||
`type` int NOT NULL,
|
||||
`priority` int NOT NULL,
|
||||
`status` int DEFAULT NULL,
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_bin
|
||||
SQL);
|
||||
|
||||
$pdo->exec(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS `assigned_task_states` (
|
||||
`id` int NOT NULL AUTO_INCREMENT,
|
||||
`state` int NOT NULL,
|
||||
`task_type` int NOT NULL,
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_bin
|
||||
SQL);
|
||||
|
||||
$pdo->exec(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS `auth_tokens` (
|
||||
`id` int NOT NULL AUTO_INCREMENT,
|
||||
`user_id` int NOT NULL,
|
||||
`token_hash` char(64) COLLATE utf8mb4_0900_bin NOT NULL,
|
||||
`expires_at` datetime NOT NULL,
|
||||
`revoked_at` datetime DEFAULT NULL,
|
||||
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`last_used_at` datetime DEFAULT NULL,
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_bin
|
||||
SQL);
|
||||
|
||||
$pdo->exec(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS `comments` (
|
||||
`id` int NOT NULL AUTO_INCREMENT,
|
||||
`response_to` int DEFAULT NULL,
|
||||
`task_id` varchar(26) COLLATE utf8mb4_0900_bin NOT NULL,
|
||||
`commenter` int NOT NULL,
|
||||
`comment` text COLLATE utf8mb4_0900_bin NOT NULL,
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_bin
|
||||
SQL);
|
||||
|
||||
$pdo->exec(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS `custom_task_fields` (
|
||||
`id` int NOT NULL AUTO_INCREMENT,
|
||||
`task_type` int NOT NULL,
|
||||
`name` varchar(128) COLLATE utf8mb4_0900_bin NOT NULL,
|
||||
`type` varchar(128) COLLATE utf8mb4_0900_bin NOT NULL,
|
||||
`default_value` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin,
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_bin
|
||||
SQL);
|
||||
|
||||
$pdo->exec(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS `custom_field_values` (
|
||||
`id` int NOT NULL AUTO_INCREMENT,
|
||||
`field_id` int NOT NULL,
|
||||
`task_id` varchar(26) COLLATE utf8mb4_0900_bin NOT NULL,
|
||||
`value` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin,
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_bin
|
||||
SQL);
|
||||
|
||||
$pdo->exec(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS `remember_tokens` (
|
||||
`id` int NOT NULL AUTO_INCREMENT,
|
||||
`user_id` int NOT NULL,
|
||||
`selector` char(32) COLLATE utf8mb4_0900_bin NOT NULL,
|
||||
`token_hash` char(64) COLLATE utf8mb4_0900_bin NOT NULL,
|
||||
`expires_at` datetime NOT NULL,
|
||||
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`last_used_at` datetime DEFAULT NULL,
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_bin
|
||||
SQL);
|
||||
|
||||
$pdo->exec(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS `rights` (
|
||||
`id` int NOT NULL AUTO_INCREMENT,
|
||||
`name` varchar(128) COLLATE utf8mb4_0900_bin NOT NULL,
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_bin
|
||||
SQL);
|
||||
|
||||
$pdo->exec(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS `settings` (
|
||||
`id` int NOT NULL AUTO_INCREMENT,
|
||||
`setting_name` varchar(128) COLLATE utf8mb4_0900_bin NOT NULL,
|
||||
`setting_value` varchar(256) COLLATE utf8mb4_0900_bin NOT NULL,
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_bin
|
||||
SQL);
|
||||
|
||||
$pdo->exec(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS `task_state_transitions` (
|
||||
`id` int NOT NULL AUTO_INCREMENT,
|
||||
`from_id` int NOT NULL,
|
||||
`to_id` int NOT NULL,
|
||||
`action_name` varchar(128) COLLATE utf8mb4_0900_bin NOT NULL,
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_bin
|
||||
SQL);
|
||||
|
||||
$pdo->exec(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS `user_access` (
|
||||
`id` int NOT NULL AUTO_INCREMENT,
|
||||
`user_id` int NOT NULL,
|
||||
`project_id` varchar(12) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin NOT NULL,
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_bin
|
||||
SQL);
|
||||
|
||||
$pdo->exec(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS `user_rights` (
|
||||
`id` int NOT NULL AUTO_INCREMENT,
|
||||
`user_id` int NOT NULL,
|
||||
`right_id` int NOT NULL,
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_bin
|
||||
SQL);
|
||||
|
||||
$pdo->exec(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS `user_settings` (
|
||||
`id` int NOT NULL AUTO_INCREMENT,
|
||||
`user_id` int NOT NULL,
|
||||
`setting_name` varchar(128) COLLATE utf8mb4_0900_bin NOT NULL,
|
||||
`setting_value` varchar(256) COLLATE utf8mb4_0900_bin NOT NULL,
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_bin
|
||||
SQL);
|
||||
|
||||
$indexes = [
|
||||
['assigned_task_states', 'state', 'KEY `state` (`state`)'],
|
||||
['assigned_task_states', 'task_type', 'KEY `task_type` (`task_type`)'],
|
||||
['auth_tokens', 'unique_token_hash', 'UNIQUE KEY `unique_token_hash` (`token_hash`)'],
|
||||
['auth_tokens', 'user_id', 'KEY `user_id` (`user_id`)'],
|
||||
['comments', 'response_to', 'KEY `response_to` (`response_to`)'],
|
||||
['comments', 'task_id', 'KEY `task_id` (`task_id`)'],
|
||||
['comments', 'commenter', 'KEY `commenter` (`commenter`)'],
|
||||
['custom_field_values', 'field_id', 'KEY `field_id` (`field_id`)'],
|
||||
['custom_field_values', 'task_id', 'KEY `task_id` (`task_id`)'],
|
||||
['custom_task_fields', 'task_type', 'KEY `task_type` (`task_type`)'],
|
||||
['projects', 'owner', 'KEY `owner` (`owner`)'],
|
||||
['remember_tokens', 'unique_selector', 'UNIQUE KEY `unique_selector` (`selector`)'],
|
||||
['remember_tokens', 'user_id', 'KEY `user_id` (`user_id`)'],
|
||||
['tasks', 'project', 'KEY `project` (`project`)'],
|
||||
['tasks', 'reporter', 'KEY `reporter` (`reporter`,`assignee`,`fix_version`,`type`,`priority`)'],
|
||||
['tasks', 'type', 'KEY `type` (`type`)'],
|
||||
['tasks', 'priority', 'KEY `priority` (`priority`)'],
|
||||
['tasks', 'assignee', 'KEY `assignee` (`assignee`)'],
|
||||
['tasks', 'fix_version', 'KEY `fix_version` (`fix_version`)'],
|
||||
['tasks', 'status', 'KEY `status` (`status`)'],
|
||||
['task_state_transitions', 'from_id', 'KEY `from_id` (`from_id`)'],
|
||||
['task_state_transitions', 'to_id', 'KEY `to_id` (`to_id`)'],
|
||||
['task_types', 'default_state', 'KEY `default_state` (`default_state`)'],
|
||||
['users', 'unique_email', 'UNIQUE KEY `unique_email` (`email`)'],
|
||||
['user_access', 'user_id', 'KEY `user_id` (`user_id`)'],
|
||||
['user_access', 'project_id', 'KEY `project_id` (`project_id`)'],
|
||||
['user_rights', 'user_id', 'KEY `user_id` (`user_id`)'],
|
||||
['user_rights', 'right_id', 'KEY `right_id` (`right_id`)'],
|
||||
['user_settings', 'user_id', 'KEY `user_id` (`user_id`)'],
|
||||
['versions', 'project', 'KEY `project` (`project`)'],
|
||||
];
|
||||
|
||||
foreach ($indexes as [$table, $index, $definition]) {
|
||||
addIndexIfMissing($pdo, $table, $index, $definition);
|
||||
}
|
||||
|
||||
upsert(
|
||||
$pdo,
|
||||
"INSERT INTO `settings` (`id`, `setting_name`, `setting_value`)
|
||||
VALUES (1, 'version', '0.0.1')
|
||||
ON DUPLICATE KEY UPDATE `setting_name` = VALUES(`setting_name`), `setting_value` = VALUES(`setting_value`)",
|
||||
[]
|
||||
);
|
||||
|
||||
$states = [
|
||||
[1, 'Open', '#858e99'],
|
||||
[2, 'In Progress', '#3384e1'],
|
||||
[3, 'Resolved', '#1a7f21'],
|
||||
[4, 'Reopened', '#8693a2'],
|
||||
[5, 'Closed', '#3c810e'],
|
||||
];
|
||||
|
||||
foreach ($states as $state) {
|
||||
upsert(
|
||||
$pdo,
|
||||
"INSERT INTO `task_states` (`id`, `name`, `color`)
|
||||
VALUES (?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE `name` = VALUES(`name`), `color` = VALUES(`color`)",
|
||||
$state
|
||||
);
|
||||
}
|
||||
|
||||
$priorities = [
|
||||
[1, 'Critical', 'images_task_priority/1_critical.svg'],
|
||||
[2, 'Heigh', 'images_task_priority/2_heigh.svg'],
|
||||
[3, 'Medium', 'images_task_priority/3_medium.svg'],
|
||||
[4, 'Low', 'images_task_priority/4_low.svg'],
|
||||
[5, 'Trivial', 'images_task_priority/5_trivial.svg'],
|
||||
];
|
||||
|
||||
foreach ($priorities as [$id, $name, $asset]) {
|
||||
upsert(
|
||||
$pdo,
|
||||
"INSERT INTO `task_priorities` (`id`, `name`, `logo`)
|
||||
VALUES (?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE `name` = VALUES(`name`), `logo` = VALUES(`logo`)",
|
||||
[$id, $name, assetBlob($asset)]
|
||||
);
|
||||
}
|
||||
|
||||
$types = [
|
||||
[1, 'Unknown', 'images_task_types/unknown.svg', 1],
|
||||
[2, 'Bug', 'images_task_types/bug.svg', 1],
|
||||
[3, 'New Feature', 'images_task_types/new_feature.svg', 1],
|
||||
[4, 'Improvement', 'images_task_types/improvement.svg', 1],
|
||||
[5, 'Task', 'images_task_types/task.svg', 1],
|
||||
];
|
||||
|
||||
foreach ($types as [$id, $name, $asset, $defaultState]) {
|
||||
upsert(
|
||||
$pdo,
|
||||
"INSERT INTO `task_types` (`id`, `name`, `logo`, `default_state`)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE `name` = VALUES(`name`), `logo` = VALUES(`logo`), `default_state` = VALUES(`default_state`)",
|
||||
[$id, $name, assetBlob($asset), $defaultState]
|
||||
);
|
||||
}
|
||||
|
||||
$transitions = [
|
||||
[1, 1, 2, 'Start Work'],
|
||||
[2, 2, 3, 'Resolve Issue'],
|
||||
[3, 3, 4, 'Reopen Issue'],
|
||||
[4, 4, 2, 'Start Work'],
|
||||
[5, 1, 5, 'Close'],
|
||||
[6, 2, 5, 'Close'],
|
||||
[7, 2, 1, 'Stop Work'],
|
||||
[8, 5, 4, 'Reopen Issue'],
|
||||
];
|
||||
|
||||
foreach ($transitions as $transition) {
|
||||
upsert(
|
||||
$pdo,
|
||||
"INSERT INTO `task_state_transitions` (`id`, `from_id`, `to_id`, `action_name`)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE `from_id` = VALUES(`from_id`), `to_id` = VALUES(`to_id`), `action_name` = VALUES(`action_name`)",
|
||||
$transition
|
||||
);
|
||||
}
|
||||
|
||||
$assignedStates = [
|
||||
[1, 1, 5], [2, 2, 5], [3, 3, 5], [4, 5, 5], [5, 4, 5],
|
||||
[6, 1, 1],
|
||||
[7, 1, 2], [8, 2, 2], [9, 3, 2], [10, 4, 2], [11, 5, 2],
|
||||
[12, 1, 3], [13, 2, 3], [14, 3, 3], [15, 4, 3], [16, 5, 3],
|
||||
[17, 1, 4], [18, 2, 4], [19, 3, 4], [20, 4, 4], [21, 5, 4],
|
||||
];
|
||||
|
||||
foreach ($assignedStates as $assignedState) {
|
||||
upsert(
|
||||
$pdo,
|
||||
"INSERT INTO `assigned_task_states` (`id`, `state`, `task_type`)
|
||||
VALUES (?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE `state` = VALUES(`state`), `task_type` = VALUES(`task_type`)",
|
||||
$assignedState
|
||||
);
|
||||
}
|
||||
|
||||
$rights = [
|
||||
[1, 'Admin'],
|
||||
[2, 'Create Tasks'],
|
||||
[3, 'Edit Tasks'],
|
||||
[4, 'Create Versions'],
|
||||
[5, 'Edit Versions'],
|
||||
[6, 'Create Projects'],
|
||||
[7, 'Edit Projects'],
|
||||
];
|
||||
|
||||
foreach ($rights as $right) {
|
||||
upsert(
|
||||
$pdo,
|
||||
"INSERT INTO `rights` (`id`, `name`)
|
||||
VALUES (?, ?)
|
||||
ON DUPLICATE KEY UPDATE `name` = VALUES(`name`)",
|
||||
$right
|
||||
);
|
||||
}
|
||||
|
||||
$pdo->exec('SET FOREIGN_KEY_CHECKS = 1');
|
||||
|
||||
$foreignKeys = [
|
||||
['assigned_task_states_ibfk_1', 'ALTER TABLE `assigned_task_states` ADD CONSTRAINT `assigned_task_states_ibfk_1` FOREIGN KEY (`task_type`) REFERENCES `task_types` (`id`) ON DELETE CASCADE ON UPDATE CASCADE'],
|
||||
['assigned_task_states_ibfk_2', 'ALTER TABLE `assigned_task_states` ADD CONSTRAINT `assigned_task_states_ibfk_2` FOREIGN KEY (`state`) REFERENCES `task_states` (`id`) ON DELETE CASCADE ON UPDATE CASCADE'],
|
||||
['auth_tokens_ibfk_1', 'ALTER TABLE `auth_tokens` ADD CONSTRAINT `auth_tokens_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE'],
|
||||
['comments_ibfk_1', 'ALTER TABLE `comments` ADD CONSTRAINT `comments_ibfk_1` FOREIGN KEY (`commenter`) REFERENCES `users` (`id`) ON DELETE CASCADE ON UPDATE CASCADE'],
|
||||
['comments_ibfk_2', 'ALTER TABLE `comments` ADD CONSTRAINT `comments_ibfk_2` FOREIGN KEY (`task_id`) REFERENCES `tasks` (`id`) ON DELETE CASCADE ON UPDATE CASCADE'],
|
||||
['comments_ibfk_3', 'ALTER TABLE `comments` ADD CONSTRAINT `comments_ibfk_3` FOREIGN KEY (`response_to`) REFERENCES `comments` (`id`) ON DELETE CASCADE ON UPDATE CASCADE'],
|
||||
['custom_field_values_ibfk_1', 'ALTER TABLE `custom_field_values` ADD CONSTRAINT `custom_field_values_ibfk_1` FOREIGN KEY (`field_id`) REFERENCES `custom_task_fields` (`id`) ON DELETE CASCADE ON UPDATE CASCADE'],
|
||||
['custom_field_values_ibfk_2', 'ALTER TABLE `custom_field_values` ADD CONSTRAINT `custom_field_values_ibfk_2` FOREIGN KEY (`task_id`) REFERENCES `tasks` (`id`) ON DELETE CASCADE ON UPDATE CASCADE'],
|
||||
['custom_task_fields_ibfk_1', 'ALTER TABLE `custom_task_fields` ADD CONSTRAINT `custom_task_fields_ibfk_1` FOREIGN KEY (`task_type`) REFERENCES `task_types` (`id`) ON DELETE CASCADE ON UPDATE CASCADE'],
|
||||
['projects_ibfk_1', 'ALTER TABLE `projects` ADD CONSTRAINT `projects_ibfk_1` FOREIGN KEY (`owner`) REFERENCES `users` (`id`) ON UPDATE CASCADE'],
|
||||
['remember_tokens_ibfk_1', 'ALTER TABLE `remember_tokens` ADD CONSTRAINT `remember_tokens_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE'],
|
||||
['tasks_ibfk_1', 'ALTER TABLE `tasks` ADD CONSTRAINT `tasks_ibfk_1` FOREIGN KEY (`type`) REFERENCES `task_types` (`id`) ON UPDATE CASCADE'],
|
||||
['tasks_ibfk_2', 'ALTER TABLE `tasks` ADD CONSTRAINT `tasks_ibfk_2` FOREIGN KEY (`priority`) REFERENCES `task_priorities` (`id`) ON UPDATE CASCADE'],
|
||||
['tasks_ibfk_3', 'ALTER TABLE `tasks` ADD CONSTRAINT `tasks_ibfk_3` FOREIGN KEY (`reporter`) REFERENCES `users` (`id`) ON UPDATE CASCADE'],
|
||||
['tasks_ibfk_4', 'ALTER TABLE `tasks` ADD CONSTRAINT `tasks_ibfk_4` FOREIGN KEY (`assignee`) REFERENCES `users` (`id`) ON UPDATE CASCADE'],
|
||||
['tasks_ibfk_5', 'ALTER TABLE `tasks` ADD CONSTRAINT `tasks_ibfk_5` FOREIGN KEY (`project`) REFERENCES `projects` (`id`) ON UPDATE CASCADE'],
|
||||
['tasks_ibfk_6', 'ALTER TABLE `tasks` ADD CONSTRAINT `tasks_ibfk_6` FOREIGN KEY (`fix_version`) REFERENCES `versions` (`id`) ON UPDATE CASCADE'],
|
||||
['tasks_ibfk_7', 'ALTER TABLE `tasks` ADD CONSTRAINT `tasks_ibfk_7` FOREIGN KEY (`status`) REFERENCES `task_states` (`id`) ON DELETE SET NULL ON UPDATE CASCADE'],
|
||||
['task_state_transitions_ibfk_1', 'ALTER TABLE `task_state_transitions` ADD CONSTRAINT `task_state_transitions_ibfk_1` FOREIGN KEY (`to_id`) REFERENCES `task_states` (`id`) ON DELETE CASCADE ON UPDATE CASCADE'],
|
||||
['task_state_transitions_ibfk_2', 'ALTER TABLE `task_state_transitions` ADD CONSTRAINT `task_state_transitions_ibfk_2` FOREIGN KEY (`from_id`) REFERENCES `task_states` (`id`) ON DELETE CASCADE ON UPDATE CASCADE'],
|
||||
['task_types_ibfk_1', 'ALTER TABLE `task_types` ADD CONSTRAINT `task_types_ibfk_1` FOREIGN KEY (`default_state`) REFERENCES `task_states` (`id`) ON DELETE SET NULL ON UPDATE CASCADE'],
|
||||
['user_access_ibfk_1', 'ALTER TABLE `user_access` ADD CONSTRAINT `user_access_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE ON UPDATE CASCADE'],
|
||||
['user_access_ibfk_2', 'ALTER TABLE `user_access` ADD CONSTRAINT `user_access_ibfk_2` FOREIGN KEY (`project_id`) REFERENCES `projects` (`id`) ON DELETE CASCADE ON UPDATE CASCADE'],
|
||||
['user_rights_ibfk_1', 'ALTER TABLE `user_rights` ADD CONSTRAINT `user_rights_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE ON UPDATE CASCADE'],
|
||||
['user_rights_ibfk_2', 'ALTER TABLE `user_rights` ADD CONSTRAINT `user_rights_ibfk_2` FOREIGN KEY (`right_id`) REFERENCES `rights` (`id`) ON DELETE CASCADE ON UPDATE CASCADE'],
|
||||
['user_settings_ibfk_1', 'ALTER TABLE `user_settings` ADD CONSTRAINT `user_settings_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE ON UPDATE CASCADE'],
|
||||
['versions_ibfk_1', 'ALTER TABLE `versions` ADD CONSTRAINT `versions_ibfk_1` FOREIGN KEY (`project`) REFERENCES `projects` (`id`) ON UPDATE CASCADE'],
|
||||
];
|
||||
|
||||
foreach ($foreignKeys as [$constraint, $definition]) {
|
||||
addForeignKeyIfMissing($pdo, $constraint, $definition);
|
||||
}
|
||||
|
||||
if (realpath($_SERVER['SCRIPT_FILENAME'] ?? '') === __FILE__) {
|
||||
header('Location: install.php');
|
||||
exit;
|
||||
}
|
||||
} catch (Throwable $exception) {
|
||||
try {
|
||||
$pdo->exec('SET FOREIGN_KEY_CHECKS = 1');
|
||||
} catch (Throwable) {
|
||||
}
|
||||
|
||||
if (realpath($_SERVER['SCRIPT_FILENAME'] ?? '') !== __FILE__) {
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
http_response_code(500);
|
||||
echo '<!doctype html><meta charset="utf-8"><title>ProjectKiln install failed</title>';
|
||||
echo '<h1>Database install failed</h1>';
|
||||
echo '<pre>' . htmlspecialchars($exception->getMessage(), ENT_QUOTES, 'UTF-8') . '</pre>';
|
||||
}
|
||||
Reference in New Issue
Block a user