docs: consolidate documentation into organized structure

- Move 12 markdown files from root to docs/ subdirectories
- Organize documentation by category:
  • docs/troubleshooting/ (1 file)  - Technical troubleshooting guides
  • docs/deployment/      (4 files) - Deployment and security documentation
  • docs/guides/          (3 files) - Feature-specific guides
  • docs/planning/        (4 files) - Planning and improvement proposals

Root directory cleanup:
- Reduced from 16 to 4 markdown files in root
- Only essential project files remain:
  • CLAUDE.md (AI instructions)
  • README.md (Main project readme)
  • CLEANUP_PLAN.md (Current cleanup plan)
  • SRC_STRUCTURE_IMPROVEMENTS.md (Structure improvements)

This improves:
 Documentation discoverability
 Logical organization by purpose
 Clean root directory
 Better maintainability
This commit is contained in:
2025-10-05 11:05:04 +02:00
parent 887847dde6
commit 5050c7d73a
36686 changed files with 196456 additions and 12398919 deletions

View File

@@ -0,0 +1,99 @@
/* Alert Component
========================================================================== */
.alert {
position: relative;
display: flex;
gap: var(--space-3, 0.75rem);
padding: var(--space-4, 1rem);
margin-block-end: var(--space-4, 1rem);
border-radius: var(--radius-md, 0.375rem);
border: 1px solid transparent;
}
/* Alert Variants */
.alert--info {
background-color: oklch(from var(--color-info, #0ea5e9) l c h / 0.1);
border-color: oklch(from var(--color-info, #0ea5e9) l c h / 0.3);
color: var(--color-info-dark, #0369a1);
}
.alert--success {
background-color: oklch(from var(--color-success, #10b981) l c h / 0.1);
border-color: oklch(from var(--color-success, #10b981) l c h / 0.3);
color: var(--color-success-dark, #047857);
}
.alert--warning {
background-color: oklch(from var(--color-warning, #f59e0b) l c h / 0.1);
border-color: oklch(from var(--color-warning, #f59e0b) l c h / 0.3);
color: var(--color-warning-dark, #b45309);
}
.alert--danger {
background-color: oklch(from var(--color-danger, #dc2626) l c h / 0.1);
border-color: oklch(from var(--color-danger, #dc2626) l c h / 0.3);
color: var(--color-danger-dark, #991b1b);
}
/* Alert Icon */
.alert__icon {
flex-shrink: 0;
width: 1.25rem;
height: 1.25rem;
}
/* Alert Content */
.alert__content {
flex: 1;
min-width: 0;
}
.alert__title {
font-weight: 600;
margin-block-end: var(--space-1, 0.25rem);
}
.alert__message {
font-size: var(--font-size-sm, 0.875rem);
line-height: 1.5;
}
/* Alert Close Button */
.alert__close {
position: absolute;
top: var(--space-2, 0.5rem);
right: var(--space-2, 0.5rem);
padding: var(--space-1, 0.25rem);
background: transparent;
border: none;
font-size: 1.5rem;
line-height: 1;
opacity: 0.5;
cursor: pointer;
transition: opacity 0.15s ease-in-out;
}
.alert__close:hover {
opacity: 1;
}
.alert--dismissible {
padding-inline-end: var(--space-10, 2.5rem);
}
/* Alert Animation */
@keyframes alert-slide-in {
from {
opacity: 0;
transform: translateY(-0.5rem);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.alert {
animation: alert-slide-in 0.2s ease-out;
}

View File

@@ -0,0 +1,71 @@
/* Badge Component
========================================================================== */
.badge {
display: inline-flex;
align-items: center;
padding: var(--space-1, 0.25rem) var(--space-2, 0.5rem);
font-size: var(--font-size-xs, 0.75rem);
font-weight: 500;
line-height: 1;
white-space: nowrap;
border-radius: var(--radius-sm, 0.25rem);
transition: background-color 0.15s ease-in-out;
}
/* Badge Sizes */
.badge--sm {
padding: 0.125rem var(--space-1, 0.25rem);
font-size: 0.625rem;
}
.badge--md {
/* Default size - already defined above */
}
.badge--lg {
padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem);
font-size: var(--font-size-sm, 0.875rem);
}
/* Badge Variants */
.badge--default {
background-color: var(--color-surface-secondary, #f3f4f6);
color: var(--color-text-primary, #1a1a1a);
}
.badge--primary {
background-color: var(--color-primary, #3b82f6);
color: white;
}
.badge--success {
background-color: var(--color-success, #10b981);
color: white;
}
.badge--warning {
background-color: var(--color-warning, #f59e0b);
color: white;
}
.badge--danger {
background-color: var(--color-danger, #dc2626);
color: white;
}
.badge--info {
background-color: var(--color-info, #0ea5e9);
color: white;
}
/* Badge Pill Variant */
.badge--pill {
border-radius: 9999px;
padding-inline: var(--space-3, 0.75rem);
}
/* Badge with Icon Support */
.badge__icon {
margin-inline-end: var(--space-1, 0.25rem);
}

View File

@@ -0,0 +1,71 @@
/* Button Group Component
========================================================================== */
.btn-group {
display: inline-flex;
vertical-align: middle;
}
.btn-group > .btn {
position: relative;
flex: 0 1 auto;
}
/* Remove border radius for middle buttons */
.btn-group > .btn:not(:first-child):not(:last-child) {
border-radius: 0;
}
.btn-group > .btn:first-child:not(:last-child) {
border-start-end-radius: 0;
border-end-end-radius: 0;
}
.btn-group > .btn:last-child:not(:first-child) {
border-start-start-radius: 0;
border-end-start-radius: 0;
}
/* Focus state layering */
.btn-group > .btn:focus {
z-index: 1;
}
/* Vertical Button Group */
.btn-group--vertical {
flex-direction: column;
align-items: flex-start;
}
.btn-group--vertical > .btn {
width: 100%;
}
.btn-group--vertical > .btn:not(:first-child):not(:last-child) {
border-radius: 0;
}
.btn-group--vertical > .btn:first-child:not(:last-child) {
border-end-start-radius: 0;
border-end-end-radius: 0;
}
.btn-group--vertical > .btn:last-child:not(:first-child) {
border-start-start-radius: 0;
border-start-end-radius: 0;
}
/* Button Group Sizes */
.btn-group--sm > .btn {
padding: var(--space-1, 0.25rem) var(--space-2, 0.5rem);
font-size: var(--font-size-sm, 0.875rem);
}
.btn-group--md > .btn {
/* Default size - inherits from .btn */
}
.btn-group--lg > .btn {
padding: var(--space-3, 0.75rem) var(--space-4, 1rem);
font-size: var(--font-size-lg, 1.125rem);
}

View File

@@ -0,0 +1,43 @@
/* Container Component
========================================================================== */
.container {
width: 100%;
margin-inline: auto;
padding-inline: var(--space-4, 1rem);
}
/* Container Sizes */
.container--sm {
max-width: 640px;
}
.container--default {
max-width: 1024px;
}
.container--lg {
max-width: 1280px;
}
.container--xl {
max-width: 1536px;
}
/* Fluid Container (no max-width) */
.container--fluid {
max-width: none;
}
/* Responsive Container */
@media (min-width: 640px) {
.container {
padding-inline: var(--space-6, 1.5rem);
}
}
@media (min-width: 1024px) {
.container {
padding-inline: var(--space-8, 2rem);
}
}

View File

@@ -0,0 +1,179 @@
/* Form Components
========================================================================== */
/* Form Group Container */
.form-group {
margin-block-end: var(--space-4, 1rem);
}
/* Form Labels */
.form-label {
display: block;
margin-block-end: var(--space-2, 0.5rem);
font-size: var(--font-size-sm, 0.875rem);
font-weight: 500;
color: var(--color-text-primary, #1a1a1a);
}
.form-label--required::after {
content: " *";
color: var(--color-danger, #dc2626);
}
/* Form Input */
.form-input {
display: block;
width: 100%;
padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem);
font-size: var(--font-size-base, 1rem);
line-height: 1.5;
color: var(--color-text-primary, #1a1a1a);
background-color: var(--color-surface, #ffffff);
border: 1px solid var(--color-border, #d1d5db);
border-radius: var(--radius-md, 0.375rem);
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
}
.form-input:focus {
outline: 0;
border-color: var(--color-primary, #3b82f6);
box-shadow: 0 0 0 3px oklch(from var(--color-primary, #3b82f6) l c h / 0.2);
}
.form-input::placeholder {
color: var(--color-text-tertiary, #9ca3af);
opacity: 1;
}
.form-input:disabled {
background-color: var(--color-surface-secondary, #f3f4f6);
opacity: 0.6;
cursor: not-allowed;
}
.form-input--error {
border-color: var(--color-danger, #dc2626);
}
.form-input--error:focus {
border-color: var(--color-danger, #dc2626);
box-shadow: 0 0 0 3px oklch(from var(--color-danger, #dc2626) l c h / 0.2);
}
/* Form Select */
.form-select {
display: block;
width: 100%;
padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem);
font-size: var(--font-size-base, 1rem);
line-height: 1.5;
color: var(--color-text-primary, #1a1a1a);
background-color: var(--color-surface, #ffffff);
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M2 5l6 6 6-6'/%3e%3c/svg%3e");
background-repeat: no-repeat;
background-position: right var(--space-3, 0.75rem) center;
background-size: 16px 12px;
border: 1px solid var(--color-border, #d1d5db);
border-radius: var(--radius-md, 0.375rem);
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
appearance: none;
}
.form-select:focus {
outline: 0;
border-color: var(--color-primary, #3b82f6);
box-shadow: 0 0 0 3px oklch(from var(--color-primary, #3b82f6) l c h / 0.2);
}
.form-select--error {
border-color: var(--color-danger, #dc2626);
}
/* Form Textarea */
.form-textarea {
display: block;
width: 100%;
padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem);
font-size: var(--font-size-base, 1rem);
line-height: 1.5;
color: var(--color-text-primary, #1a1a1a);
background-color: var(--color-surface, #ffffff);
border: 1px solid var(--color-border, #d1d5db);
border-radius: var(--radius-md, 0.375rem);
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
resize: vertical;
}
.form-textarea:focus {
outline: 0;
border-color: var(--color-primary, #3b82f6);
box-shadow: 0 0 0 3px oklch(from var(--color-primary, #3b82f6) l c h / 0.2);
}
.form-textarea--error {
border-color: var(--color-danger, #dc2626);
}
/* Checkbox Group */
.form-checkbox-group {
display: flex;
align-items: flex-start;
gap: var(--space-2, 0.5rem);
margin-block-end: var(--space-4, 1rem);
}
.form-checkbox {
width: 1rem;
height: 1rem;
margin-block-start: 0.25rem;
flex-shrink: 0;
cursor: pointer;
}
.form-checkbox:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.form-checkbox-label {
font-size: var(--font-size-base, 1rem);
color: var(--color-text-primary, #1a1a1a);
cursor: pointer;
user-select: none;
}
/* Radio Group */
.form-radio-item {
display: flex;
align-items: flex-start;
gap: var(--space-2, 0.5rem);
margin-block-end: var(--space-3, 0.75rem);
}
.form-radio {
width: 1rem;
height: 1rem;
margin-block-start: 0.25rem;
flex-shrink: 0;
cursor: pointer;
}
.form-radio:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.form-radio-label {
font-size: var(--font-size-base, 1rem);
color: var(--color-text-primary, #1a1a1a);
cursor: pointer;
user-select: none;
}
/* Form Error Message */
.form-error {
display: block;
margin-block-start: var(--space-2, 0.5rem);
font-size: var(--font-size-sm, 0.875rem);
color: var(--color-danger, #dc2626);
}

View File

@@ -0,0 +1,915 @@
/* ==========================================================================
Image Manager Components
========================================================================== */
/* Upload Area Styles
========================================================================== */
.image-upload-area {
position: relative;
border: 2px dashed var(--color-border-subtle);
border-radius: var(--border-radius-lg);
background: var(--color-surface-subtle);
transition: all 0.2s ease;
min-height: 200px;
}
.image-upload-area:hover,
.image-upload-area:focus-within {
border-color: var(--color-primary);
background: var(--color-surface-elevated);
}
.image-upload-area--drag-over {
border-color: var(--color-success);
background: var(--color-success-subtle);
transform: scale(1.02);
}
.upload-area__content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--space-xl);
text-align: center;
gap: var(--space-md);
}
.upload-area__icon {
color: var(--color-text-subtle);
opacity: 0.6;
}
.upload-area__text h3 {
margin: 0 0 var(--space-xs) 0;
color: var(--color-text-primary);
font-size: var(--font-size-lg);
font-weight: var(--font-weight-semibold);
}
.upload-area__text p {
margin: 0;
color: var(--color-text-subtle);
font-size: var(--font-size-sm);
}
.upload-area__button {
background: var(--color-primary);
color: var(--color-primary-contrast);
border: none;
border-radius: var(--border-radius-md);
padding: var(--space-sm) var(--space-lg);
font-size: var(--font-size-base);
font-weight: var(--font-weight-medium);
cursor: pointer;
transition: all 0.2s ease;
}
.upload-area__button:hover {
background: var(--color-primary-hover);
transform: translateY(-1px);
}
.upload-area__preview {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: var(--space-sm);
padding: var(--space-md);
border-top: 1px solid var(--color-border-subtle);
}
.preview-item {
position: relative;
aspect-ratio: 1;
border-radius: var(--border-radius-md);
overflow: hidden;
background: var(--color-surface-elevated);
}
.preview-item__image {
width: 100%;
height: 100%;
object-fit: cover;
}
.preview-item__info {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.8));
padding: var(--space-xs);
color: white;
font-size: var(--font-size-xs);
}
.preview-item__name {
font-weight: var(--font-weight-medium);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.preview-item__size {
opacity: 0.8;
font-size: var(--font-size-xs);
}
.upload-area__progress {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: var(--color-surface-elevated);
border-top: 1px solid var(--color-border-subtle);
padding: var(--space-md);
border-radius: 0 0 var(--border-radius-lg) var(--border-radius-lg);
}
.progress-bar {
background: var(--color-background-muted);
border-radius: var(--border-radius-full);
height: 8px;
overflow: hidden;
margin-bottom: var(--space-xs);
}
.progress-bar__fill {
background: var(--color-primary);
height: 100%;
border-radius: var(--border-radius-full);
transition: width 0.3s ease;
width: 0%;
}
.progress-text {
font-size: var(--font-size-sm);
color: var(--color-text-subtle);
text-align: center;
}
/* Image Gallery Styles
========================================================================== */
.image-gallery {
display: flex;
flex-direction: column;
gap: var(--space-lg);
}
.gallery__controls {
display: flex;
gap: var(--space-md);
align-items: center;
flex-wrap: wrap;
}
.gallery__search-wrapper {
position: relative;
flex: 1;
min-width: 200px;
}
.gallery__search {
width: 100%;
padding: var(--space-sm) var(--space-md);
border: 1px solid var(--color-border-subtle);
border-radius: var(--border-radius-md);
font-size: var(--font-size-base);
background: var(--color-surface);
}
.gallery__search:focus {
border-color: var(--color-primary);
outline: none;
box-shadow: 0 0 0 3px var(--color-primary-alpha-20);
}
.gallery__search-clear {
position: absolute;
right: var(--space-sm);
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
font-size: var(--font-size-lg);
color: var(--color-text-subtle);
cursor: pointer;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--border-radius-sm);
}
.gallery__search-clear:hover {
background: var(--color-surface-subtle);
color: var(--color-text-primary);
}
.gallery__sort-wrapper {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.gallery__sort {
padding: var(--space-sm) var(--space-md);
border: 1px solid var(--color-border-subtle);
border-radius: var(--border-radius-md);
background: var(--color-surface);
font-size: var(--font-size-base);
}
.gallery__grid {
display: grid;
grid-template-columns: repeat(var(--columns, 4), 1fr);
gap: var(--space-lg);
grid-auto-rows: min-content;
}
@media (max-width: 1200px) {
.gallery__grid {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 768px) {
.gallery__grid {
grid-template-columns: repeat(2, 1fr);
gap: var(--space-md);
}
}
@media (max-width: 480px) {
.gallery__grid {
grid-template-columns: 1fr;
}
}
.gallery__item {
position: relative;
border-radius: var(--border-radius-lg);
overflow: hidden;
background: var(--color-surface);
box-shadow: var(--shadow-sm);
transition: all 0.2s ease;
}
.gallery__item:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
.gallery__item--selected {
box-shadow: 0 0 0 3px var(--color-primary);
}
.gallery__item-inner {
display: flex;
flex-direction: column;
}
.gallery__item-image {
position: relative;
aspect-ratio: 4/3;
overflow: hidden;
}
.gallery__item-image img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.2s ease;
}
.gallery__item:hover .gallery__item-image img {
transform: scale(1.05);
}
.gallery__item-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s ease;
}
.gallery__item:hover .gallery__item-overlay {
opacity: 1;
}
.gallery__item-actions {
display: flex;
gap: var(--space-sm);
}
.action-btn {
background: rgba(255, 255, 255, 0.9);
border: none;
border-radius: var(--border-radius-full);
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
font-size: var(--font-size-base);
}
.action-btn:hover {
background: white;
transform: scale(1.1);
}
.action-btn.select-btn {
background: var(--color-primary);
color: white;
}
.action-btn.delete-btn:hover {
background: var(--color-danger);
color: white;
}
.gallery__item-info {
padding: var(--space-md);
}
.gallery__item-name {
font-weight: var(--font-weight-medium);
margin-bottom: var(--space-xs);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.gallery__item-meta {
display: flex;
gap: var(--space-sm);
font-size: var(--font-size-xs);
color: var(--color-text-subtle);
}
.meta-item {
background: var(--color-surface-subtle);
padding: var(--space-xs) var(--space-sm);
border-radius: var(--border-radius-sm);
}
.gallery__pagination {
display: flex;
justify-content: center;
padding: var(--space-lg) 0;
}
.gallery__load-more {
background: var(--color-primary);
color: var(--color-primary-contrast);
border: none;
border-radius: var(--border-radius-md);
padding: var(--space-md) var(--space-xl);
font-size: var(--font-size-base);
font-weight: var(--font-weight-medium);
cursor: pointer;
transition: all 0.2s ease;
}
.gallery__load-more:hover:not(:disabled) {
background: var(--color-primary-hover);
transform: translateY(-1px);
}
.gallery__load-more:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.gallery__loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--space-xl);
color: var(--color-text-subtle);
}
.gallery__empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--space-xxl);
text-align: center;
color: var(--color-text-subtle);
}
.empty-state__icon {
margin-bottom: var(--space-lg);
opacity: 0.5;
}
.gallery__empty h3 {
margin: 0 0 var(--space-sm) 0;
color: var(--color-text-primary);
}
.gallery__empty p {
margin: 0;
font-size: var(--font-size-base);
}
.gallery__error {
margin: var(--space-md) 0;
}
.error-message {
background: var(--color-danger-subtle);
border: 1px solid var(--color-danger);
border-radius: var(--border-radius-md);
padding: var(--space-md);
display: flex;
align-items: center;
gap: var(--space-sm);
}
.error-icon {
font-size: var(--font-size-lg);
}
.error-text {
flex: 1;
color: var(--color-danger-contrast);
}
.error-close {
background: none;
border: none;
font-size: var(--font-size-lg);
color: var(--color-danger-contrast);
cursor: pointer;
opacity: 0.7;
}
.error-close:hover {
opacity: 1;
}
/* Image Modal Styles
========================================================================== */
.image-modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1000;
display: none;
}
.image-modal--open {
display: block;
}
.image-modal__backdrop {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.8);
backdrop-filter: blur(4px);
}
.image-modal__container {
position: relative;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
background: var(--color-surface);
animation: modalSlideIn 0.3s ease;
}
@keyframes modalSlideIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.image-modal__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-lg);
border-bottom: 1px solid var(--color-border-subtle);
background: var(--color-surface-elevated);
}
.image-modal__title {
margin: 0;
font-size: var(--font-size-lg);
font-weight: var(--font-weight-semibold);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
margin-right: var(--space-md);
}
.image-modal__actions {
display: flex;
gap: var(--space-sm);
}
.modal-btn {
border: none;
border-radius: var(--border-radius-md);
padding: var(--space-sm);
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
}
.modal-btn--primary {
background: var(--color-primary);
color: var(--color-primary-contrast);
}
.modal-btn--secondary {
background: var(--color-surface-subtle);
color: var(--color-text-primary);
}
.modal-btn--danger {
background: var(--color-danger-subtle);
color: var(--color-danger);
}
.modal-btn--ghost {
background: transparent;
color: var(--color-text-subtle);
}
.modal-btn--small {
width: auto;
height: auto;
padding: var(--space-xs) var(--space-sm);
font-size: var(--font-size-sm);
}
.modal-btn--success {
background: var(--color-success) !important;
color: var(--color-success-contrast) !important;
}
.modal-btn:hover {
opacity: 0.8;
transform: translateY(-1px);
}
.modal-close {
background: transparent;
border: none;
color: var(--color-text-subtle);
cursor: pointer;
padding: var(--space-sm);
}
.image-modal__content {
display: flex;
flex: 1;
overflow: hidden;
}
.image-modal__image-container {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-background-muted);
position: relative;
overflow: hidden;
}
.image-modal__image {
max-width: 100%;
max-height: 100%;
object-fit: contain;
border-radius: var(--border-radius-md);
}
.image-modal__loading {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-md);
}
.image-modal__sidebar {
width: 400px;
background: var(--color-surface-elevated);
border-left: 1px solid var(--color-border-subtle);
overflow-y: auto;
display: flex;
flex-direction: column;
}
@media (max-width: 1024px) {
.image-modal__content {
flex-direction: column;
}
.image-modal__sidebar {
width: 100%;
max-height: 40%;
border-left: none;
border-top: 1px solid var(--color-border-subtle);
}
}
.image-modal__metadata,
.image-modal__alt-text,
.image-modal__variants {
padding: var(--space-lg);
border-bottom: 1px solid var(--color-border-subtle);
}
.image-modal__metadata h3,
.image-modal__alt-text h3,
.image-modal__variants h3 {
margin: 0 0 var(--space-md) 0;
font-size: var(--font-size-base);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
}
.metadata-grid {
display: grid;
gap: var(--space-sm);
}
.metadata-item {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-sm);
align-items: center;
}
.metadata-item label {
font-size: var(--font-size-sm);
color: var(--color-text-subtle);
font-weight: var(--font-weight-medium);
}
.metadata-value {
font-size: var(--font-size-sm);
color: var(--color-text-primary);
}
.metadata-value--hash {
font-family: var(--font-mono);
word-break: break-all;
background: var(--color-surface-subtle);
padding: var(--space-xs);
border-radius: var(--border-radius-sm);
}
.alt-text-input {
width: 100%;
min-height: 80px;
padding: var(--space-sm);
border: 1px solid var(--color-border-subtle);
border-radius: var(--border-radius-md);
font-size: var(--font-size-sm);
font-family: inherit;
resize: vertical;
background: var(--color-surface);
}
.alt-text-input:focus {
border-color: var(--color-primary);
outline: none;
box-shadow: 0 0 0 3px var(--color-primary-alpha-20);
}
.alt-text-actions {
margin-top: var(--space-sm);
}
.variants-list {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.variant-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-sm);
background: var(--color-surface-subtle);
border-radius: var(--border-radius-md);
}
.variant-info strong {
display: block;
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
}
.variant-dimensions {
font-size: var(--font-size-xs);
color: var(--color-text-subtle);
}
.variant-link {
color: var(--color-primary);
text-decoration: none;
padding: var(--space-xs);
border-radius: var(--border-radius-sm);
transition: background 0.2s ease;
}
.variant-link:hover {
background: var(--color-primary-alpha-10);
}
.no-variants {
text-align: center;
color: var(--color-text-subtle);
font-style: italic;
margin: 0;
}
.image-error {
text-align: center;
padding: var(--space-xl);
color: var(--color-text-subtle);
}
.image-error__icon {
font-size: 3rem;
margin-bottom: var(--space-md);
}
.image-error h3 {
margin: 0 0 var(--space-sm) 0;
color: var(--color-text-primary);
}
.image-error p {
margin: 0 0 var(--space-lg) 0;
}
/* Notification Styles
========================================================================== */
.notification {
position: fixed;
top: var(--space-lg);
right: var(--space-lg);
z-index: 1100;
max-width: 400px;
background: var(--color-surface-elevated);
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-lg);
opacity: 0;
transform: translateX(100%);
transition: all 0.3s ease;
}
.notification--visible {
opacity: 1;
transform: translateX(0);
}
.notification--success {
border-left: 4px solid var(--color-success);
}
.notification--error {
border-left: 4px solid var(--color-danger);
}
.notification--info {
border-left: 4px solid var(--color-primary);
}
.notification__content {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-md);
}
.notification__message {
flex: 1;
margin-right: var(--space-sm);
}
.notification__close {
background: none;
border: none;
font-size: var(--font-size-lg);
color: var(--color-text-subtle);
cursor: pointer;
padding: var(--space-xs);
border-radius: var(--border-radius-sm);
}
.notification__close:hover {
background: var(--color-surface-subtle);
}
/* Loading Spinner
========================================================================== */
.loading-spinner {
width: 40px;
height: 40px;
border: 3px solid var(--color-border-subtle);
border-top: 3px solid var(--color-primary);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* Modal Open State
========================================================================== */
body.modal-open {
overflow: hidden;
}
/* Focus Styles
========================================================================== */
.image-upload-area:focus {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
.gallery__item:focus-within {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
/* Reduced Motion
========================================================================== */
@media (prefers-reduced-motion: reduce) {
.image-modal__container,
.notification,
.gallery__item,
.upload-area__button,
.modal-btn,
.action-btn {
transition: none;
animation: none;
}
.loading-spinner {
animation: none;
border: 3px solid var(--color-primary);
}
}
/* Print Styles
========================================================================== */
@media print {
.image-modal,
.notification,
.gallery__item-overlay,
.upload-area__button {
display: none !important;
}
}

View File

@@ -13,6 +13,12 @@
@import url('components/lightbox.css') layer(components);
@import url('components/csrf-status.css') layer(components);
@import url('components/form-autosave.css') layer(components);
@import url('components/image-manager.css') layer(components);
@import url('components/forms.css') layer(components);
@import url('components/badge.css') layer(components);
@import url('components/alert.css') layer(components);
@import url('components/button-group.css') layer(components);
@import url('components/container.css') layer(components);
@import url('forms/inputs.css') layer(components);
/* Admin Components */

View File

@@ -5,6 +5,13 @@ import { Logger } from './core/logger.js';
import { CsrfAutoRefresh } from './modules/csrf-auto-refresh.js';
import { FormAutoSave } from './modules/form-autosave.js';
// Import Hot Reload in development
if (import.meta.env.DEV) {
import('./modules/hot-reload.js').then(({ HotReload }) => {
console.log('🔥 Hot Reload module loaded');
});
}
// PWA Service Worker temporarily disabled
// TODO: Re-enable after fixing build issues

View File

@@ -0,0 +1,295 @@
import { Module } from '../core/module.js';
/**
* Admin Data Table Module
*
* Provides interactive functionality for admin data tables:
* - AJAX loading and pagination
* - Sorting by columns
* - Search/filtering
* - Auto-refresh
*/
export class AdminDataTable extends Module {
constructor(element) {
super(element);
this.resource = element.dataset.resource;
this.apiEndpoint = element.dataset.apiEndpoint;
this.sortable = element.dataset.sortable === 'true';
this.searchable = element.dataset.searchable === 'true';
this.paginated = element.dataset.paginated === 'true';
this.perPage = parseInt(element.dataset.perPage) || 25;
this.currentPage = 1;
this.currentSort = null;
this.currentSearch = '';
}
async init() {
if (!this.apiEndpoint) {
console.warn('AdminDataTable: No API endpoint configured');
return;
}
this.tbody = this.element.querySelector('tbody');
this.thead = this.element.querySelector('thead');
if (!this.tbody) {
console.warn('AdminDataTable: No tbody found');
return;
}
this.setupEventListeners();
// Only load data if table is initially empty
if (this.tbody.children.length === 0) {
await this.loadData();
}
}
setupEventListeners() {
// Sortable columns
if (this.sortable && this.thead) {
this.thead.querySelectorAll('th').forEach((th, index) => {
const columnKey = th.dataset.column || this.getColumnKeyFromIndex(index);
if (columnKey) {
th.style.cursor = 'pointer';
th.classList.add('sortable');
th.addEventListener('click', () => {
this.sortByColumn(columnKey, th);
});
}
});
}
// Search input
if (this.searchable) {
const searchInput = this.getSearchInput();
if (searchInput) {
searchInput.addEventListener('input', this.debounce(() => {
this.currentSearch = searchInput.value;
this.currentPage = 1;
this.loadData();
}, 300));
}
}
// Pagination links (if exist)
this.setupPaginationListeners();
}
setupPaginationListeners() {
const paginationContainer = this.getPaginationContainer();
if (!paginationContainer) return;
paginationContainer.addEventListener('click', (e) => {
const pageLink = e.target.closest('[data-page]');
if (pageLink) {
e.preventDefault();
const page = parseInt(pageLink.dataset.page);
if (page && page !== this.currentPage) {
this.currentPage = page;
this.loadData();
}
}
});
}
async sortByColumn(columnKey, th) {
// Toggle sort direction
const currentDir = th.dataset.sortDir;
const newDir = currentDir === 'asc' ? 'desc' : 'asc';
// Update sort state
this.currentSort = { column: columnKey, direction: newDir };
// Update UI
this.thead.querySelectorAll('th').forEach(header => {
header.removeAttribute('data-sort-dir');
header.classList.remove('sort-asc', 'sort-desc');
});
th.dataset.sortDir = newDir;
th.classList.add(`sort-${newDir}`);
// Reload data
await this.loadData();
}
async loadData() {
const params = new URLSearchParams({
page: this.currentPage.toString(),
per_page: this.perPage.toString(),
});
if (this.currentSearch) {
params.set('search', this.currentSearch);
}
if (this.currentSort) {
params.set('sort_by', this.currentSort.column);
params.set('sort_dir', this.currentSort.direction);
}
const url = `${this.apiEndpoint}?${params}`;
try {
this.setLoadingState(true);
const response = await fetch(url);
const result = await response.json();
if (result.success) {
this.renderRows(result.data);
if (this.paginated && result.pagination) {
this.renderPagination(result.pagination);
}
} else {
this.showError(result.error || 'Failed to load data');
}
} catch (error) {
console.error('AdminDataTable: Failed to load data', error);
this.showError('Failed to load data');
} finally {
this.setLoadingState(false);
}
}
renderRows(data) {
if (!data || data.length === 0) {
this.tbody.innerHTML = this.getEmptyRow();
return;
}
// Get column keys from header
const columnKeys = this.getColumnKeys();
this.tbody.innerHTML = data.map(item => {
const cells = columnKeys.map(key => {
const value = item[key] !== undefined ? item[key] : '';
return `<td>${this.escapeHtml(value)}</td>`;
}).join('');
return `<tr data-id="${item.id || ''}">${cells}</tr>`;
}).join('');
}
renderPagination(pagination) {
const container = this.getPaginationContainer();
if (!container) return;
const { page, pages, total } = pagination;
if (pages <= 1) {
container.innerHTML = '';
return;
}
const links = [];
// Previous
if (page > 1) {
links.push(`<a href="#" data-page="${page - 1}" class="page-link">Previous</a>`);
}
// Page numbers
for (let i = 1; i <= pages; i++) {
if (i === page) {
links.push(`<span class="page-link active">${i}</span>`);
} else {
links.push(`<a href="#" data-page="${i}" class="page-link">${i}</a>`);
}
}
// Next
if (page < pages) {
links.push(`<a href="#" data-page="${page + 1}" class="page-link">Next</a>`);
}
container.innerHTML = `
<div class="pagination">
${links.join('')}
<span class="pagination-info">Total: ${total}</span>
</div>
`;
}
setLoadingState(loading) {
if (loading) {
this.element.classList.add('loading');
this.tbody.style.opacity = '0.5';
} else {
this.element.classList.remove('loading');
this.tbody.style.opacity = '1';
}
}
showError(message) {
const columnCount = this.getColumnKeys().length;
this.tbody.innerHTML = `
<tr>
<td colspan="${columnCount}" class="error-message">
${this.escapeHtml(message)}
</td>
</tr>
`;
}
getEmptyRow() {
const columnCount = this.getColumnKeys().length;
return `
<tr>
<td colspan="${columnCount}" class="empty-message">
No data available
</td>
</tr>
`;
}
getColumnKeys() {
if (!this.thead) return [];
const headers = this.thead.querySelectorAll('th');
return Array.from(headers).map((th, index) =>
th.dataset.column || this.getColumnKeyFromIndex(index)
).filter(Boolean);
}
getColumnKeyFromIndex(index) {
// Fallback: use column index as key
return `col_${index}`;
}
getSearchInput() {
return document.querySelector(`[data-table-search="${this.resource}"]`);
}
getPaginationContainer() {
return document.querySelector(`[data-table-pagination="${this.resource}"]`) ||
this.element.parentElement.querySelector('.pagination-container');
}
escapeHtml(str) {
const div = document.createElement('div');
div.textContent = String(str);
return div.innerHTML;
}
debounce(func, wait) {
let timeout;
return (...args) => {
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(this, args), wait);
};
}
}
// Auto-initialize all admin data tables
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('[data-resource][data-api-endpoint]').forEach(table => {
new AdminDataTable(table).init();
});
});

View File

@@ -0,0 +1,291 @@
/**
* Hot Reload Client for Development
* Connects to SSE stream and handles reload events
*/
export class HotReload {
constructor(options = {}) {
this.url = options.url || '/dev/hot-reload';
this.reconnectDelay = options.reconnectDelay || 5000;
this.maxReconnectAttempts = options.maxReconnectAttempts || 10;
this.debug = options.debug || false;
this.eventSource = null;
this.reconnectAttempts = 0;
this.reconnectTimer = null;
// Only enable in development
if (this.isDevelopment()) {
this.connect();
this.setupVisibilityHandlers();
}
}
/**
* Check if we're in development mode
*/
isDevelopment() {
return window.location.hostname === 'localhost'
|| window.location.hostname === '127.0.0.1'
|| window.location.hostname.includes('.local');
}
/**
* Connect to SSE stream
*/
connect() {
if (this.eventSource) {
return;
}
this.log('Connecting to Hot Reload server...');
this.eventSource = new EventSource(this.url);
// Connection opened
this.eventSource.addEventListener('open', () => {
this.log('Hot Reload connected');
this.reconnectAttempts = 0;
this.showNotification('Hot Reload Connected', 'success');
});
// Handle reload events
this.eventSource.addEventListener('reload', (event) => {
const data = JSON.parse(event.data);
this.handleReload(data);
});
// Handle heartbeat
this.eventSource.addEventListener('heartbeat', () => {
this.log('Heartbeat received');
});
// Handle close
this.eventSource.addEventListener('close', () => {
this.log('Server requested close');
this.disconnect();
});
// Handle errors
this.eventSource.addEventListener('error', (error) => {
this.log('Connection error', error);
this.disconnect();
this.scheduleReconnect();
});
}
/**
* Disconnect from SSE stream
*/
disconnect() {
if (this.eventSource) {
this.eventSource.close();
this.eventSource = null;
}
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
}
/**
* Schedule reconnection attempt
*/
scheduleReconnect() {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
this.log('Max reconnection attempts reached');
this.showNotification('Hot Reload disconnected', 'error');
return;
}
this.reconnectAttempts++;
const delay = Math.min(this.reconnectDelay * this.reconnectAttempts, 30000);
this.log(`Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`);
this.reconnectTimer = setTimeout(() => {
this.connect();
}, delay);
}
/**
* Handle reload event from server
*/
handleReload(data) {
this.log('Reload event received', data);
switch (data.type) {
case 'css':
this.reloadCSS(data.file);
break;
case 'hmr':
this.handleHMR(data);
break;
case 'partial':
this.reloadPartial(data);
break;
case 'full':
default:
this.reloadPage();
break;
}
}
/**
* Reload CSS without page refresh
*/
reloadCSS(file) {
this.log('Reloading CSS:', file);
const links = document.querySelectorAll('link[rel="stylesheet"]');
const timestamp = new Date().getTime();
links.forEach(link => {
const href = link.getAttribute('href');
if (href) {
// Remove existing timestamp
const cleanHref = href.split('?')[0];
// Add new timestamp to force reload
link.setAttribute('href', `${cleanHref}?t=${timestamp}`);
}
});
this.showNotification('CSS updated', 'info', 2000);
}
/**
* Handle Hot Module Replacement
*/
handleHMR(data) {
this.log('HMR not yet implemented, falling back to full reload');
this.reloadPage();
}
/**
* Reload partial content
*/
reloadPartial(data) {
this.log('Partial reload not yet implemented, falling back to full reload');
this.reloadPage();
}
/**
* Reload the entire page
*/
reloadPage() {
this.log('Reloading page...');
this.showNotification('Reloading...', 'info', 500);
setTimeout(() => {
window.location.reload();
}, 500);
}
/**
* Show notification to user
*/
showNotification(message, type = 'info', duration = 3000) {
// Remove existing notification
const existing = document.getElementById('hot-reload-notification');
if (existing) {
existing.remove();
}
// Create notification element
const notification = document.createElement('div');
notification.id = 'hot-reload-notification';
notification.textContent = message;
notification.className = `hot-reload-notification hot-reload-notification--${type}`;
// Style the notification
notification.style.cssText = `
position: fixed;
bottom: 20px;
right: 20px;
padding: 12px 20px;
border-radius: 6px;
font-family: system-ui, -apple-system, sans-serif;
font-size: 14px;
font-weight: 500;
z-index: 999999;
transition: opacity 0.3s ease;
pointer-events: none;
max-width: 300px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
`;
// Type-specific styles
switch(type) {
case 'success':
notification.style.backgroundColor = '#10b981';
notification.style.color = '#ffffff';
break;
case 'error':
notification.style.backgroundColor = '#ef4444';
notification.style.color = '#ffffff';
break;
case 'warning':
notification.style.backgroundColor = '#f59e0b';
notification.style.color = '#ffffff';
break;
default:
notification.style.backgroundColor = '#3b82f6';
notification.style.color = '#ffffff';
}
document.body.appendChild(notification);
// Auto-remove after duration
if (duration > 0) {
setTimeout(() => {
notification.style.opacity = '0';
setTimeout(() => {
notification.remove();
}, 300);
}, duration);
}
}
/**
* Setup visibility handlers for reconnection
*/
setupVisibilityHandlers() {
// Reconnect when tab becomes visible
document.addEventListener('visibilitychange', () => {
if (!document.hidden && !this.eventSource) {
this.log('Tab became visible, reconnecting...');
this.connect();
}
});
// Disconnect when window is closed
window.addEventListener('beforeunload', () => {
this.disconnect();
});
}
/**
* Debug logging
*/
log(...args) {
if (this.debug) {
console.log('[HotReload]', ...args);
}
}
}
// Auto-initialize if in development
if (typeof window !== 'undefined') {
window.addEventListener('DOMContentLoaded', () => {
// Only initialize if not already initialized
if (!window.__hotReload) {
window.__hotReload = new HotReload({
debug: true // Enable debug in development
});
}
});
}

View File

@@ -0,0 +1,75 @@
export class EventEmitter {
constructor() {
this.events = new Map();
}
on(event, listener) {
if (!this.events.has(event)) {
this.events.set(event, []);
}
this.events.get(event).push(listener);
return this; // Allow chaining
}
off(event, listener) {
if (!this.events.has(event)) {
return this;
}
const listeners = this.events.get(event);
const index = listeners.indexOf(listener);
if (index > -1) {
listeners.splice(index, 1);
}
// Remove event key if no listeners remain
if (listeners.length === 0) {
this.events.delete(event);
}
return this;
}
emit(event, ...args) {
if (!this.events.has(event)) {
return false;
}
const listeners = this.events.get(event);
for (const listener of listeners) {
try {
listener.apply(this, args);
} catch (error) {
console.error('Error in event listener:', error);
}
}
return true;
}
once(event, listener) {
const onceWrapper = (...args) => {
this.off(event, onceWrapper);
listener.apply(this, args);
};
return this.on(event, onceWrapper);
}
removeAllListeners(event) {
if (event) {
this.events.delete(event);
} else {
this.events.clear();
}
return this;
}
listenerCount(event) {
return this.events.has(event) ? this.events.get(event).length : 0;
}
eventNames() {
return Array.from(this.events.keys());
}
}

View File

@@ -0,0 +1,542 @@
import { Logger } from '../../core/logger.js';
import { EventEmitter } from './EventEmitter.js';
export class ImageGallery extends EventEmitter {
constructor(config, state) {
super();
this.config = config;
this.state = state;
this.galleries = new Map();
this.currentImages = new Map();
this.currentPage = 1;
this.hasMore = true;
this.isLoading = false;
this.searchTerm = '';
this.sortField = 'ulid';
this.sortDirection = 'desc';
}
async init() {
const elements = document.querySelectorAll('[data-image-gallery]');
for (const element of elements) {
await this.initializeGallery(element);
}
Logger.info(`[ImageGallery] Initialized ${elements.length} galleries`);
}
async initializeGallery(element) {
const config = this.parseElementConfig(element);
const gallery = new Gallery(element, config, this);
this.galleries.set(element, gallery);
await gallery.init();
}
parseElementConfig(element) {
return {
pageSize: parseInt(element.dataset.pageSize) || 20,
columns: parseInt(element.dataset.columns) || 4,
showPagination: element.hasAttribute('data-pagination'),
showSearch: element.hasAttribute('data-search'),
showSort: element.hasAttribute('data-sort'),
allowDelete: element.hasAttribute('data-allow-delete'),
allowEdit: element.hasAttribute('data-allow-edit'),
selectable: element.hasAttribute('data-selectable'),
...this.config
};
}
async loadImages(page = 1, search = '', sort = 'ulid', direction = 'desc') {
if (this.isLoading) return;
this.isLoading = true;
this.emit('gallery:loading', true);
try {
const params = new URLSearchParams({
page: page.toString(),
limit: this.config.pageSize?.toString() || '20',
sort: sort,
direction: direction
});
if (search) {
params.append('search', search);
}
const response = await fetch(`${this.config.listEndpoint}?${params}`, {
headers: {
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
}
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
this.currentPage = page;
this.hasMore = data.meta.has_more;
this.searchTerm = search;
this.sortField = sort;
this.sortDirection = direction;
// Store images for this gallery instance
const galleryKey = `${search}_${sort}_${direction}`;
if (page === 1) {
this.currentImages.set(galleryKey, data.data);
} else {
const existing = this.currentImages.get(galleryKey) || [];
this.currentImages.set(galleryKey, [...existing, ...data.data]);
}
this.emit('gallery:loaded', {
images: this.currentImages.get(galleryKey),
meta: data.meta,
page,
hasMore: this.hasMore
});
return data;
} catch (error) {
Logger.error('[ImageGallery] Failed to load images:', error);
this.emit('gallery:error', error);
throw error;
} finally {
this.isLoading = false;
this.emit('gallery:loading', false);
}
}
async deleteImage(imageId) {
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') ||
document.querySelector('input[name="_token"]')?.value;
const response = await fetch(`${this.config.uploadEndpoint}/${imageId}`, {
method: 'DELETE',
headers: {
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-TOKEN': csrfToken || ''
}
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || `HTTP ${response.status}`);
}
// Remove from current images
for (const [key, images] of this.currentImages) {
const filtered = images.filter(img => img.ulid !== imageId);
this.currentImages.set(key, filtered);
}
this.emit('image:delete', { imageId });
return true;
} catch (error) {
Logger.error('[ImageGallery] Failed to delete image:', error);
this.emit('gallery:error', error);
throw error;
}
}
async refresh() {
await this.loadImages(1, this.searchTerm, this.sortField, this.sortDirection);
}
destroy() {
for (const [element, gallery] of this.galleries) {
gallery.destroy();
}
this.galleries.clear();
this.currentImages.clear();
this.removeAllListeners();
}
}
class Gallery {
constructor(element, config, manager) {
this.element = element;
this.config = config;
this.manager = manager;
this.gridContainer = null;
this.searchInput = null;
this.sortSelect = null;
this.loadMoreButton = null;
this.currentImages = [];
this.selectedImages = new Set();
}
async init() {
this.createHTML();
this.setupEventListeners();
await this.loadInitialImages();
}
createHTML() {
this.element.classList.add('image-gallery');
this.element.innerHTML = `
${this.config.showSearch ? this.createSearchHTML() : ''}
${this.config.showSort ? this.createSortHTML() : ''}
<div class="gallery__grid" style="--columns: ${this.config.columns}"></div>
${this.config.showPagination ? this.createPaginationHTML() : ''}
<div class="gallery__loading" style="display: none;">
<div class="loading-spinner"></div>
<p>Loading images...</p>
</div>
<div class="gallery__empty" style="display: none;">
<div class="empty-state__icon">
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1">
<path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z"></path>
<circle cx="12" cy="13" r="3"></circle>
</svg>
</div>
<h3>No images found</h3>
<p>Upload some images to get started!</p>
</div>
`;
this.gridContainer = this.element.querySelector('.gallery__grid');
this.searchInput = this.element.querySelector('.gallery__search');
this.sortSelect = this.element.querySelector('.gallery__sort');
this.loadMoreButton = this.element.querySelector('.gallery__load-more');
}
createSearchHTML() {
return `
<div class="gallery__controls">
<div class="gallery__search-wrapper">
<input type="text" class="gallery__search" placeholder="Search images...">
<button type="button" class="gallery__search-clear" style="display: none;">&times;</button>
</div>
</div>
`;
}
createSortHTML() {
return `
<div class="gallery__sort-wrapper">
<label for="gallery-sort">Sort by:</label>
<select class="gallery__sort" id="gallery-sort">
<option value="ulid:desc">Newest first</option>
<option value="ulid:asc">Oldest first</option>
<option value="filename:asc">Name A-Z</option>
<option value="filename:desc">Name Z-A</option>
<option value="fileSize:desc">Largest first</option>
<option value="fileSize:asc">Smallest first</option>
</select>
</div>
`;
}
createPaginationHTML() {
return `
<div class="gallery__pagination">
<button type="button" class="gallery__load-more" style="display: none;">
Load More Images
</button>
</div>
`;
}
setupEventListeners() {
// Search functionality
if (this.searchInput) {
let searchTimeout;
this.searchInput.addEventListener('input', (e) => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
this.handleSearch(e.target.value);
}, 300);
});
const clearButton = this.element.querySelector('.gallery__search-clear');
if (clearButton) {
clearButton.addEventListener('click', () => {
this.searchInput.value = '';
this.handleSearch('');
});
this.searchInput.addEventListener('input', (e) => {
clearButton.style.display = e.target.value ? 'block' : 'none';
});
}
}
// Sort functionality
if (this.sortSelect) {
this.sortSelect.addEventListener('change', (e) => {
const [field, direction] = e.target.value.split(':');
this.handleSort(field, direction);
});
}
// Load more functionality
if (this.loadMoreButton) {
this.loadMoreButton.addEventListener('click', () => {
this.loadMore();
});
}
// Manager event listeners
this.manager.on('gallery:loaded', (data) => {
this.renderImages(data.images);
this.updatePagination(data.hasMore);
});
this.manager.on('gallery:loading', (isLoading) => {
this.toggleLoading(isLoading);
});
this.manager.on('gallery:error', (error) => {
this.showError(error.message);
});
// Intersection Observer for infinite scroll
if (this.config.showPagination) {
this.setupInfiniteScroll();
}
}
setupInfiniteScroll() {
const observer = new IntersectionObserver((entries) => {
const [entry] = entries;
if (entry.isIntersecting && this.manager.hasMore && !this.manager.isLoading) {
this.loadMore();
}
}, {
rootMargin: '100px'
});
// Observe the load more button
if (this.loadMoreButton) {
observer.observe(this.loadMoreButton);
}
}
async loadInitialImages() {
await this.manager.loadImages(1);
}
async handleSearch(term) {
await this.manager.loadImages(1, term, this.manager.sortField, this.manager.sortDirection);
}
async handleSort(field, direction) {
await this.manager.loadImages(1, this.manager.searchTerm, field, direction);
}
async loadMore() {
if (this.manager.hasMore && !this.manager.isLoading) {
await this.manager.loadImages(
this.manager.currentPage + 1,
this.manager.searchTerm,
this.manager.sortField,
this.manager.sortDirection
);
}
}
renderImages(images) {
this.currentImages = images;
if (images.length === 0) {
this.showEmptyState();
return;
}
this.hideEmptyState();
this.gridContainer.innerHTML = '';
images.forEach(image => {
const imageElement = this.createImageElement(image);
this.gridContainer.appendChild(imageElement);
});
}
createImageElement(image) {
const imageItem = document.createElement('div');
imageItem.className = 'gallery__item';
imageItem.dataset.imageId = image.ulid;
const aspectRatio = image.dimensions.width / image.dimensions.height;
const gridSpan = aspectRatio > 1.5 ? 2 : 1; // Wide images span 2 columns
imageItem.style.gridColumn = `span ${Math.min(gridSpan, this.config.columns)}`;
imageItem.innerHTML = `
<div class="gallery__item-inner">
<div class="gallery__item-image">
<img src="${image.thumbnail_url}" alt="${image.alt_text || image.filename}"
loading="lazy" draggable="false">
<div class="gallery__item-overlay">
<div class="gallery__item-actions">
${this.config.selectable ? '<button class="action-btn select-btn" title="Select">✓</button>' : ''}
<button class="action-btn view-btn" title="View">👁</button>
${this.config.allowEdit ? '<button class="action-btn edit-btn" title="Edit">✏️</button>' : ''}
${this.config.allowDelete ? '<button class="action-btn delete-btn" title="Delete">🗑</button>' : ''}
</div>
</div>
</div>
<div class="gallery__item-info">
<div class="gallery__item-name" title="${image.filename}">${image.filename}</div>
<div class="gallery__item-meta">
<span class="meta-item">${image.dimensions.width}×${image.dimensions.height}</span>
<span class="meta-item">${image.file_size.human_readable}</span>
<span class="meta-item">${image.mime_type.split('/')[1].toUpperCase()}</span>
</div>
</div>
</div>
`;
this.setupImageEventListeners(imageItem, image);
return imageItem;
}
setupImageEventListeners(imageItem, image) {
// View button
const viewBtn = imageItem.querySelector('.view-btn');
if (viewBtn) {
viewBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.manager.emit('image:view', image);
});
}
// Edit button
const editBtn = imageItem.querySelector('.edit-btn');
if (editBtn) {
editBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.manager.emit('image:edit', image);
});
}
// Delete button
const deleteBtn = imageItem.querySelector('.delete-btn');
if (deleteBtn) {
deleteBtn.addEventListener('click', async (e) => {
e.stopPropagation();
if (confirm(`Delete "${image.filename}"?`)) {
try {
await this.manager.deleteImage(image.ulid);
imageItem.remove();
} catch (error) {
alert('Failed to delete image: ' + error.message);
}
}
});
}
// Select button
const selectBtn = imageItem.querySelector('.select-btn');
if (selectBtn) {
selectBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.toggleSelection(imageItem, image);
});
}
// Double-click to view
imageItem.addEventListener('dblclick', () => {
this.manager.emit('image:view', image);
});
}
toggleSelection(imageItem, image) {
if (this.selectedImages.has(image.ulid)) {
this.selectedImages.delete(image.ulid);
imageItem.classList.remove('gallery__item--selected');
} else {
this.selectedImages.add(image.ulid);
imageItem.classList.add('gallery__item--selected');
}
this.manager.emit('image:select', {
image,
selected: this.selectedImages.has(image.ulid),
selectedCount: this.selectedImages.size
});
}
updatePagination(hasMore) {
if (this.loadMoreButton) {
this.loadMoreButton.style.display = hasMore ? 'block' : 'none';
}
}
toggleLoading(isLoading) {
const loadingElement = this.element.querySelector('.gallery__loading');
if (loadingElement) {
loadingElement.style.display = isLoading ? 'block' : 'none';
}
if (this.loadMoreButton) {
this.loadMoreButton.disabled = isLoading;
this.loadMoreButton.textContent = isLoading ? 'Loading...' : 'Load More Images';
}
}
showEmptyState() {
const emptyElement = this.element.querySelector('.gallery__empty');
if (emptyElement) {
emptyElement.style.display = 'block';
}
this.gridContainer.style.display = 'none';
}
hideEmptyState() {
const emptyElement = this.element.querySelector('.gallery__empty');
if (emptyElement) {
emptyElement.style.display = 'none';
}
this.gridContainer.style.display = 'grid';
}
showError(message) {
// Create or update error message
let errorElement = this.element.querySelector('.gallery__error');
if (!errorElement) {
errorElement = document.createElement('div');
errorElement.className = 'gallery__error';
this.element.appendChild(errorElement);
}
errorElement.innerHTML = `
<div class="error-message">
<span class="error-icon">⚠️</span>
<span class="error-text">${message}</span>
<button class="error-close">&times;</button>
</div>
`;
// Auto-remove after 5 seconds
setTimeout(() => {
if (errorElement.parentNode) {
errorElement.parentNode.removeChild(errorElement);
}
}, 5000);
// Close button
errorElement.querySelector('.error-close').addEventListener('click', () => {
if (errorElement.parentNode) {
errorElement.parentNode.removeChild(errorElement);
}
});
}
destroy() {
this.element.innerHTML = '';
this.element.classList.remove('image-gallery');
}
}

View File

@@ -0,0 +1,519 @@
import { Logger } from '../../core/logger.js';
import { EventEmitter } from './EventEmitter.js';
export class ImageModal extends EventEmitter {
constructor(config, state) {
super();
this.config = config;
this.state = state;
this.modal = null;
this.currentImage = null;
this.isOpen = false;
this.keydownHandler = null;
}
async init() {
this.createModal();
this.setupEventListeners();
this.setupKeyboardNavigation();
Logger.info('[ImageModal] Image modal initialized');
}
createModal() {
// Create modal container
this.modal = document.createElement('div');
this.modal.className = 'image-modal';
this.modal.setAttribute('role', 'dialog');
this.modal.setAttribute('aria-modal', 'true');
this.modal.setAttribute('aria-labelledby', 'modal-title');
this.modal.innerHTML = `
<div class="image-modal__backdrop"></div>
<div class="image-modal__container">
<div class="image-modal__header">
<h2 id="modal-title" class="image-modal__title"></h2>
<div class="image-modal__actions">
<button type="button" class="modal-btn modal-btn--secondary" data-action="download" title="Download">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7,10 12,15 17,10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
</button>
<button type="button" class="modal-btn modal-btn--secondary" data-action="edit" title="Edit">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
</svg>
</button>
<button type="button" class="modal-btn modal-btn--danger" data-action="delete" title="Delete">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3,6 5,6 21,6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
</button>
<button type="button" class="modal-btn modal-btn--ghost modal-close" data-action="close" title="Close (Esc)">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
</div>
<div class="image-modal__content">
<div class="image-modal__image-container">
<img class="image-modal__image" alt="" draggable="false">
<div class="image-modal__loading">
<div class="loading-spinner"></div>
</div>
</div>
<div class="image-modal__sidebar">
<div class="image-modal__metadata">
<h3>Image Information</h3>
<div class="metadata-grid">
<div class="metadata-item">
<label>Filename:</label>
<span class="metadata-value" data-field="filename"></span>
</div>
<div class="metadata-item">
<label>Dimensions:</label>
<span class="metadata-value" data-field="dimensions"></span>
</div>
<div class="metadata-item">
<label>File Size:</label>
<span class="metadata-value" data-field="fileSize"></span>
</div>
<div class="metadata-item">
<label>Format:</label>
<span class="metadata-value" data-field="format"></span>
</div>
<div class="metadata-item">
<label>Orientation:</label>
<span class="metadata-value" data-field="orientation"></span>
</div>
<div class="metadata-item">
<label>Aspect Ratio:</label>
<span class="metadata-value" data-field="aspectRatio"></span>
</div>
<div class="metadata-item">
<label>Created:</label>
<span class="metadata-value" data-field="created"></span>
</div>
<div class="metadata-item">
<label>Hash:</label>
<span class="metadata-value metadata-value--hash" data-field="hash"></span>
</div>
</div>
</div>
<div class="image-modal__alt-text">
<h3>Alt Text</h3>
<div class="alt-text-editor">
<textarea class="alt-text-input" placeholder="Add alt text for accessibility..."></textarea>
<div class="alt-text-actions">
<button type="button" class="modal-btn modal-btn--small modal-btn--primary" data-action="save-alt">
Save Alt Text
</button>
</div>
</div>
</div>
${this.createVariantsSection()}
</div>
</div>
</div>
`;
// Append to body
document.body.appendChild(this.modal);
}
createVariantsSection() {
return `
<div class="image-modal__variants">
<h3>Available Variants</h3>
<div class="variants-list">
<!-- Variants will be populated dynamically -->
</div>
</div>
`;
}
setupEventListeners() {
// Close modal events
const closeBtn = this.modal.querySelector('[data-action="close"]');
const backdrop = this.modal.querySelector('.image-modal__backdrop');
closeBtn.addEventListener('click', () => this.close());
backdrop.addEventListener('click', () => this.close());
// Action buttons
this.modal.querySelector('[data-action="download"]').addEventListener('click', () => {
this.downloadImage();
});
this.modal.querySelector('[data-action="edit"]').addEventListener('click', () => {
this.editImage();
});
this.modal.querySelector('[data-action="delete"]').addEventListener('click', () => {
this.deleteImage();
});
this.modal.querySelector('[data-action="save-alt"]').addEventListener('click', () => {
this.saveAltText();
});
// Image load events
const image = this.modal.querySelector('.image-modal__image');
image.addEventListener('load', () => {
this.hideLoading();
});
image.addEventListener('error', () => {
this.hideLoading();
this.showImageError();
});
// Listen for image view events from gallery
document.addEventListener('image:view', (e) => {
this.open(e.detail);
});
}
setupKeyboardNavigation() {
this.keydownHandler = (e) => {
if (!this.isOpen) return;
switch (e.key) {
case 'Escape':
e.preventDefault();
this.close();
break;
case 'Enter':
if (e.target.classList.contains('alt-text-input')) {
e.preventDefault();
this.saveAltText();
}
break;
}
};
document.addEventListener('keydown', this.keydownHandler);
}
open(image) {
if (this.isOpen) {
this.close();
}
this.currentImage = image;
this.isOpen = true;
// Update modal content
this.updateModalContent(image);
// Show modal
this.modal.classList.add('image-modal--open');
document.body.classList.add('modal-open');
// Focus management
this.modal.querySelector('.modal-close').focus();
// Trap focus
this.trapFocus();
this.emit('modal:open', image);
Logger.info('[ImageModal] Opened modal for image:', image.filename);
}
close() {
if (!this.isOpen) return;
this.isOpen = false;
this.currentImage = null;
// Hide modal
this.modal.classList.remove('image-modal--open');
document.body.classList.remove('modal-open');
// Clear image
const image = this.modal.querySelector('.image-modal__image');
image.src = '';
this.emit('modal:close');
Logger.info('[ImageModal] Modal closed');
}
updateModalContent(image) {
// Update title
const title = this.modal.querySelector('.image-modal__title');
title.textContent = image.filename;
// Update image
this.showLoading();
const modalImage = this.modal.querySelector('.image-modal__image');
modalImage.src = image.url;
modalImage.alt = image.alt_text || image.filename;
// Update metadata
this.updateMetadata(image);
// Update alt text
const altTextInput = this.modal.querySelector('.alt-text-input');
altTextInput.value = image.alt_text || '';
// Update variants
this.updateVariants(image);
}
updateMetadata(image) {
const metadata = {
filename: image.filename,
dimensions: `${image.dimensions.width} × ${image.dimensions.height}`,
fileSize: image.file_size.human_readable,
format: image.mime_type.split('/')[1].toUpperCase(),
orientation: image.dimensions.orientation,
aspectRatio: image.dimensions.aspect_ratio.toFixed(2),
created: new Date(image.created_at).toLocaleDateString(),
hash: image.hash
};
for (const [field, value] of Object.entries(metadata)) {
const element = this.modal.querySelector(`[data-field="${field}"]`);
if (element) {
element.textContent = value;
// Special handling for hash (make it copyable)
if (field === 'hash') {
element.title = 'Click to copy';
element.style.cursor = 'pointer';
element.onclick = () => {
navigator.clipboard.writeText(value).then(() => {
element.textContent = 'Copied!';
setTimeout(() => {
element.textContent = value;
}, 1000);
});
};
}
}
}
}
updateVariants(image) {
const variantsList = this.modal.querySelector('.variants-list');
if (!image.variants || image.variants.length === 0) {
variantsList.innerHTML = '<p class="no-variants">No variants available</p>';
return;
}
variantsList.innerHTML = image.variants.map(variant => `
<div class="variant-item">
<div class="variant-info">
<strong>${variant.type}</strong>
<span class="variant-dimensions">${variant.width}×${variant.height}</span>
</div>
<a href="${variant.url}" class="variant-link" target="_blank" title="View variant">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path>
<polyline points="15,3 21,3 21,9"></polyline>
<line x1="10" y1="14" x2="21" y2="3"></line>
</svg>
</a>
</div>
`).join('');
}
showLoading() {
const loading = this.modal.querySelector('.image-modal__loading');
const image = this.modal.querySelector('.image-modal__image');
loading.style.display = 'flex';
image.style.display = 'none';
}
hideLoading() {
const loading = this.modal.querySelector('.image-modal__loading');
const image = this.modal.querySelector('.image-modal__image');
loading.style.display = 'none';
image.style.display = 'block';
}
showImageError() {
const container = this.modal.querySelector('.image-modal__image-container');
container.innerHTML = `
<div class="image-error">
<div class="image-error__icon">⚠️</div>
<h3>Failed to load image</h3>
<p>The image could not be loaded. It may have been deleted or moved.</p>
<button type="button" class="modal-btn modal-btn--primary" onclick="location.reload()">
Refresh Page
</button>
</div>
`;
}
async downloadImage() {
if (!this.currentImage) return;
try {
const response = await fetch(this.currentImage.url);
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = this.currentImage.filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
this.emit('image:download', this.currentImage);
} catch (error) {
Logger.error('[ImageModal] Download failed:', error);
alert('Failed to download image: ' + error.message);
}
}
editImage() {
if (!this.currentImage) return;
this.emit('image:edit', this.currentImage);
this.close();
}
async deleteImage() {
if (!this.currentImage) return;
if (!confirm(`Delete "${this.currentImage.filename}"?`)) {
return;
}
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') ||
document.querySelector('input[name="_token"]')?.value;
const response = await fetch(`${this.config.uploadEndpoint}/${this.currentImage.ulid}`, {
method: 'DELETE',
headers: {
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-TOKEN': csrfToken || ''
}
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || `HTTP ${response.status}`);
}
this.emit('image:delete', this.currentImage);
this.close();
} catch (error) {
Logger.error('[ImageModal] Delete failed:', error);
alert('Failed to delete image: ' + error.message);
}
}
async saveAltText() {
if (!this.currentImage) return;
const altTextInput = this.modal.querySelector('.alt-text-input');
const newAltText = altTextInput.value.trim();
if (newAltText === this.currentImage.alt_text) {
return; // No change
}
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') ||
document.querySelector('input[name="_token"]')?.value;
const response = await fetch(`${this.config.uploadEndpoint}/${this.currentImage.ulid}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-TOKEN': csrfToken || ''
},
body: JSON.stringify({
alt_text: newAltText
})
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || `HTTP ${response.status}`);
}
// Update current image data
this.currentImage.alt_text = newAltText;
// Update the modal image alt text
const modalImage = this.modal.querySelector('.image-modal__image');
modalImage.alt = newAltText || this.currentImage.filename;
// Show success feedback
const saveBtn = this.modal.querySelector('[data-action="save-alt"]');
const originalText = saveBtn.textContent;
saveBtn.textContent = 'Saved!';
saveBtn.classList.add('modal-btn--success');
setTimeout(() => {
saveBtn.textContent = originalText;
saveBtn.classList.remove('modal-btn--success');
}, 1500);
this.emit('image:update', this.currentImage);
} catch (error) {
Logger.error('[ImageModal] Save alt text failed:', error);
alert('Failed to save alt text: ' + error.message);
}
}
trapFocus() {
const focusableElements = this.modal.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
const handleTabKey = (e) => {
if (e.key === 'Tab') {
if (e.shiftKey) {
if (document.activeElement === firstElement) {
e.preventDefault();
lastElement.focus();
}
} else {
if (document.activeElement === lastElement) {
e.preventDefault();
firstElement.focus();
}
}
}
};
this.modal.addEventListener('keydown', handleTabKey);
}
destroy() {
if (this.isOpen) {
this.close();
}
if (this.keydownHandler) {
document.removeEventListener('keydown', this.keydownHandler);
}
if (this.modal && this.modal.parentNode) {
this.modal.parentNode.removeChild(this.modal);
}
this.removeAllListeners();
}
}

View File

@@ -0,0 +1,385 @@
import { Logger } from '../../core/logger.js';
import { EventEmitter } from './EventEmitter.js';
export class ImageUploader extends EventEmitter {
constructor(config, state) {
super();
this.config = config;
this.state = state;
this.uploadAreas = new Map();
this.activeUploads = new Map();
}
async init() {
const elements = document.querySelectorAll('[data-image-upload]');
for (const element of elements) {
await this.initializeUploadArea(element);
}
Logger.info(`[ImageUploader] Initialized ${elements.length} upload areas`);
}
async initializeUploadArea(element) {
const config = this.parseElementConfig(element);
const uploadArea = new UploadArea(element, config, this);
this.uploadAreas.set(element, uploadArea);
await uploadArea.init();
}
parseElementConfig(element) {
return {
multiple: element.hasAttribute('data-multiple'),
maxFiles: parseInt(element.dataset.maxFiles) || 10,
accept: element.dataset.accept || this.config.allowedTypes.join(','),
maxSize: parseInt(element.dataset.maxSize) || this.config.maxFileSize,
preview: element.hasAttribute('data-preview'),
...this.config
};
}
async uploadFile(file, config, progressCallback) {
const uploadId = this.generateUploadId();
try {
// Validate file
this.validateFile(file, config);
// Create form data
const formData = new FormData();
formData.append('image', file);
// Add CSRF token if available
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') ||
document.querySelector('input[name="_token"]')?.value;
if (csrfToken) {
formData.append('_token', csrfToken);
}
// Create XMLHttpRequest for progress tracking
const xhr = new XMLHttpRequest();
// Track active upload
this.activeUploads.set(uploadId, { xhr, file });
// Setup progress tracking
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable) {
const progress = Math.round((event.loaded / event.total) * 100);
progressCallback?.(progress);
this.emit('upload:progress', { uploadId, file, progress });
}
});
// Create upload promise
const uploadPromise = new Promise((resolve, reject) => {
xhr.onload = () => {
this.activeUploads.delete(uploadId);
if (xhr.status >= 200 && xhr.status < 300) {
try {
const response = JSON.parse(xhr.responseText);
resolve(response);
} catch (e) {
reject(new Error('Invalid JSON response'));
}
} else {
try {
const errorResponse = JSON.parse(xhr.responseText);
reject(new Error(errorResponse.message || `Upload failed: ${xhr.status}`));
} catch (e) {
reject(new Error(`Upload failed: ${xhr.status} ${xhr.statusText}`));
}
}
};
xhr.onerror = () => {
this.activeUploads.delete(uploadId);
reject(new Error('Network error during upload'));
};
xhr.onabort = () => {
this.activeUploads.delete(uploadId);
reject(new Error('Upload cancelled'));
};
});
// Start upload
xhr.open('POST', config.uploadEndpoint);
xhr.send(formData);
const result = await uploadPromise;
this.emit('upload:success', result);
return result;
} catch (error) {
this.activeUploads.delete(uploadId);
this.emit('upload:error', error);
throw error;
}
}
validateFile(file, config) {
// Check file type
if (!config.allowedTypes.includes(file.type)) {
throw new Error(`File type ${file.type} is not allowed`);
}
// Check file size
if (file.size > config.maxSize) {
const maxSizeMB = Math.round(config.maxSize / (1024 * 1024));
throw new Error(`File size exceeds ${maxSizeMB}MB limit`);
}
// Check if it's actually an image
if (!file.type.startsWith('image/')) {
throw new Error('File is not an image');
}
}
cancelUpload(uploadId) {
const upload = this.activeUploads.get(uploadId);
if (upload) {
upload.xhr.abort();
this.activeUploads.delete(uploadId);
this.emit('upload:cancelled', { uploadId, file: upload.file });
}
}
generateUploadId() {
return 'upload_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
}
destroy() {
// Cancel all active uploads
for (const [uploadId] of this.activeUploads) {
this.cancelUpload(uploadId);
}
// Destroy all upload areas
for (const [element, uploadArea] of this.uploadAreas) {
uploadArea.destroy();
}
this.uploadAreas.clear();
this.removeAllListeners();
}
}
class UploadArea {
constructor(element, config, uploader) {
this.element = element;
this.config = config;
this.uploader = uploader;
this.fileInput = null;
this.previewContainer = null;
this.uploadQueue = [];
this.isDragOver = false;
}
async init() {
this.createHTML();
this.setupEventListeners();
this.setupDragAndDrop();
}
createHTML() {
this.element.classList.add('image-upload-area');
this.element.innerHTML = `
<div class="upload-area__content">
<div class="upload-area__icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z"></path>
<circle cx="12" cy="13" r="3"></circle>
</svg>
</div>
<div class="upload-area__text">
<h3>Drop images here or click to browse</h3>
<p>Supports ${this.config.allowedTypes.map(type => type.split('/')[1].toUpperCase()).join(', ')}
up to ${Math.round(this.config.maxSize / (1024 * 1024))}MB</p>
</div>
<button type="button" class="upload-area__button">Choose Files</button>
<input type="file" class="upload-area__input" ${this.config.multiple ? 'multiple' : ''}
accept="${this.config.accept}" style="display: none;">
</div>
${this.config.preview ? '<div class="upload-area__preview"></div>' : ''}
<div class="upload-area__progress" style="display: none;">
<div class="progress-bar">
<div class="progress-bar__fill"></div>
</div>
<div class="progress-text">Uploading...</div>
</div>
`;
this.fileInput = this.element.querySelector('.upload-area__input');
if (this.config.preview) {
this.previewContainer = this.element.querySelector('.upload-area__preview');
}
}
setupEventListeners() {
const button = this.element.querySelector('.upload-area__button');
const content = this.element.querySelector('.upload-area__content');
// Button and area click handlers
button.addEventListener('click', () => this.fileInput.click());
content.addEventListener('click', (e) => {
if (e.target === content || e.target.closest('.upload-area__text')) {
this.fileInput.click();
}
});
// File input change
this.fileInput.addEventListener('change', (e) => {
this.handleFiles(Array.from(e.target.files));
});
// Keyboard accessibility
this.element.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
this.fileInput.click();
}
});
// Make area focusable
this.element.setAttribute('tabindex', '0');
this.element.setAttribute('role', 'button');
this.element.setAttribute('aria-label', 'Upload images');
}
setupDragAndDrop() {
const events = ['dragenter', 'dragover', 'dragleave', 'drop'];
events.forEach(eventName => {
this.element.addEventListener(eventName, (e) => {
e.preventDefault();
e.stopPropagation();
});
});
this.element.addEventListener('dragenter', () => {
this.isDragOver = true;
this.element.classList.add('upload-area--drag-over');
});
this.element.addEventListener('dragleave', (e) => {
// Only remove drag styles if leaving the entire upload area
if (!this.element.contains(e.relatedTarget)) {
this.isDragOver = false;
this.element.classList.remove('upload-area--drag-over');
}
});
this.element.addEventListener('drop', (e) => {
this.isDragOver = false;
this.element.classList.remove('upload-area--drag-over');
const files = Array.from(e.dataTransfer.files).filter(file =>
file.type.startsWith('image/')
);
if (files.length > 0) {
this.handleFiles(files);
}
});
}
async handleFiles(files) {
// Validate file count
if (!this.config.multiple && files.length > 1) {
files = [files[0]];
}
if (files.length > this.config.maxFiles) {
this.uploader.emit('upload:error',
new Error(`Maximum ${this.config.maxFiles} files allowed`)
);
return;
}
// Show previews if enabled
if (this.config.preview) {
this.showPreviews(files);
}
// Upload files
for (const file of files) {
await this.uploadFile(file);
}
}
showPreviews(files) {
if (!this.previewContainer) return;
this.previewContainer.innerHTML = '';
files.forEach(file => {
const previewItem = document.createElement('div');
previewItem.className = 'preview-item';
const img = document.createElement('img');
img.className = 'preview-item__image';
img.alt = file.name;
const info = document.createElement('div');
info.className = 'preview-item__info';
info.innerHTML = `
<div class="preview-item__name">${file.name}</div>
<div class="preview-item__size">${this.formatFileSize(file.size)}</div>
`;
previewItem.appendChild(img);
previewItem.appendChild(info);
this.previewContainer.appendChild(previewItem);
// Create image preview
const reader = new FileReader();
reader.onload = (e) => {
img.src = e.target.result;
};
reader.readAsDataURL(file);
});
}
async uploadFile(file) {
const progressBar = this.element.querySelector('.upload-area__progress');
const progressFill = this.element.querySelector('.progress-bar__fill');
const progressText = this.element.querySelector('.progress-text');
try {
// Show progress
progressBar.style.display = 'block';
progressText.textContent = `Uploading ${file.name}...`;
await this.uploader.uploadFile(file, this.config, (progress) => {
progressFill.style.width = `${progress}%`;
progressText.textContent = `Uploading ${file.name}... ${progress}%`;
});
// Hide progress on success
progressBar.style.display = 'none';
} catch (error) {
// Hide progress on error
progressBar.style.display = 'none';
throw error;
}
}
formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
destroy() {
// Remove event listeners and clean up
this.element.innerHTML = '';
this.element.classList.remove('image-upload-area', 'upload-area--drag-over');
}
}

View File

@@ -0,0 +1,446 @@
import { Logger } from '../../core/logger.js';
export const definition = {
name: 'image-manager',
version: '1.0.0',
dependencies: [],
provides: ['image-upload', 'image-gallery', 'image-modal'],
priority: 5
};
const ImageManagerModule = {
name: 'image-manager',
activeGalleries: new Map(),
activeUploaders: new Map(),
async init(config = {}, state) {
Logger.info('[ImageManager] Module initialized');
// Initialize all image gallery elements
this.initializeGalleries();
// Initialize all image uploader elements
this.initializeUploaders();
// Set up modal functionality
this.initializeModal();
return this;
},
initializeGalleries() {
const galleryElements = document.querySelectorAll('[data-image-gallery]');
Logger.info(`[ImageManager] Found ${galleryElements.length} gallery elements`);
galleryElements.forEach((element, index) => {
const galleryId = `gallery-${index}`;
const gallery = new ImageGallery(element, {
listEndpoint: element.dataset.listEndpoint || '/api/images',
pageSize: parseInt(element.dataset.pageSize) || 20,
columns: parseInt(element.dataset.columns) || 4
});
this.activeGalleries.set(galleryId, gallery);
Logger.info(`[ImageManager] Initialized gallery: ${galleryId}`);
});
},
initializeUploaders() {
const uploaderElements = document.querySelectorAll('[data-image-uploader]');
Logger.info(`[ImageManager] Found ${uploaderElements.length} uploader elements`);
uploaderElements.forEach((element, index) => {
const uploaderId = `uploader-${index}`;
const uploader = new ImageUploader(element, {
uploadUrl: element.dataset.uploadUrl || '/api/images',
maxFileSize: parseInt(element.dataset.maxFileSize) || 10485760,
allowedTypes: element.dataset.allowedTypes?.split(',') || ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
maxFiles: parseInt(element.dataset.maxFiles) || 10
});
this.activeUploaders.set(uploaderId, uploader);
Logger.info(`[ImageManager] Initialized uploader: ${uploaderId}`);
});
},
initializeModal() {
const modal = new ImageModal();
window.ImageModal = modal;
Logger.info('[ImageManager] Modal initialized');
},
destroy() {
// Destroy all galleries
this.activeGalleries.forEach((gallery, id) => {
gallery.destroy();
Logger.info(`[ImageManager] Destroyed gallery: ${id}`);
});
this.activeGalleries.clear();
// Destroy all uploaders
this.activeUploaders.forEach((uploader, id) => {
uploader.destroy();
Logger.info(`[ImageManager] Destroyed uploader: ${id}`);
});
this.activeUploaders.clear();
// Clean up modal
if (window.ImageModal) {
window.ImageModal.destroy();
delete window.ImageModal;
}
Logger.info('[ImageManager] Module destroyed');
}
};
// Image Gallery Implementation
class ImageGallery {
constructor(element, config) {
this.element = element;
this.config = config;
this.currentPage = 1;
this.images = [];
this.loading = false;
this.init();
}
async init() {
Logger.info('[ImageGallery] Initializing gallery');
// Clear loading state and build UI
this.element.innerHTML = '';
this.buildGalleryUI();
// Load images
await this.loadImages();
}
buildGalleryUI() {
this.element.innerHTML = `
<div class="image-gallery">
<div class="gallery__controls">
<div class="gallery__search-wrapper">
<input type="text" class="gallery__search" placeholder="Search images...">
<button type="button" class="gallery__search-clear">&times;</button>
</div>
<div class="gallery__sort-wrapper">
<label>Sort:</label>
<select class="gallery__sort">
<option value="created_desc">Newest First</option>
<option value="created_asc">Oldest First</option>
<option value="name_asc">Name A-Z</option>
<option value="name_desc">Name Z-A</option>
</select>
</div>
</div>
<div class="gallery__grid" style="--columns: ${this.config.columns}"></div>
<div class="gallery__pagination">
<button type="button" class="gallery__load-more" style="display: none;">Load More</button>
</div>
<div class="gallery__loading">
<div class="loading-spinner"></div>
<p>Loading images...</p>
</div>
</div>
`;
// Add event listeners
this.setupEventListeners();
}
setupEventListeners() {
const searchInput = this.element.querySelector('.gallery__search');
const searchClear = this.element.querySelector('.gallery__search-clear');
const sortSelect = this.element.querySelector('.gallery__sort');
const loadMoreBtn = this.element.querySelector('.gallery__load-more');
// Search functionality
let searchTimeout;
searchInput.addEventListener('input', (e) => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
this.searchImages(e.target.value);
}, 300);
});
searchClear.addEventListener('click', () => {
searchInput.value = '';
this.searchImages('');
});
// Sort functionality
sortSelect.addEventListener('change', (e) => {
this.sortImages(e.target.value);
});
// Load more functionality
loadMoreBtn.addEventListener('click', () => {
this.loadMoreImages();
});
}
async loadImages() {
if (this.loading) return;
this.loading = true;
this.showLoading();
try {
Logger.info('[ImageGallery] Loading images from:', this.config.listEndpoint);
const response = await fetch(`${this.config.listEndpoint}?page=${this.currentPage}&limit=${this.config.pageSize}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const response_data = await response.json();
Logger.info('[ImageGallery] API Response:', response_data);
// Handle different API response formats
let images = [];
let metaInfo = {};
if (response_data.data && Array.isArray(response_data.data)) {
// Format: {data: [...], meta: {...}}
images = response_data.data;
metaInfo = response_data.meta || {};
} else if (response_data.images && Array.isArray(response_data.images)) {
// Format: {images: [...]}
images = response_data.images;
metaInfo = response_data;
} else if (Array.isArray(response_data)) {
// Format: [...]
images = response_data;
metaInfo = {};
} else {
Logger.warn('[ImageGallery] Unexpected API response format:', response_data);
images = [];
metaInfo = {};
}
Logger.info('[ImageGallery] Processed images:', images.length);
if (this.currentPage === 1) {
this.images = images;
} else {
this.images = [...this.images, ...images];
}
this.renderImages();
this.updateLoadMoreButton(metaInfo);
} catch (error) {
Logger.error('[ImageGallery] Failed to load images:', error);
this.showError(`Failed to load images: ${error.message}`);
} finally {
this.loading = false;
this.hideLoading();
}
}
renderImages() {
const grid = this.element.querySelector('.gallery__grid');
if (this.images.length === 0) {
grid.innerHTML = `
<div class="gallery__empty">
<div class="empty-state__icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<circle cx="8.5" cy="8.5" r="1.5"></circle>
<polyline points="21,15 16,10 5,21"></polyline>
</svg>
</div>
<h3>No images found</h3>
<p>Upload some images to get started</p>
</div>
`;
return;
}
grid.innerHTML = this.images.map(image => this.renderImageItem(image)).join('');
// Add click handlers to gallery items
grid.querySelectorAll('.gallery__item').forEach((item, index) => {
item.addEventListener('click', () => {
this.showImageModal(this.images[index]);
});
});
}
renderImageItem(image) {
// Handle different API response formats for file size and dimensions
let fileSize = '0 B';
let dimensions = '0 × 0';
if (image.file_size) {
if (typeof image.file_size === 'object' && image.file_size.bytes) {
fileSize = this.formatFileSize(image.file_size.bytes);
} else if (typeof image.file_size === 'number') {
fileSize = this.formatFileSize(image.file_size);
}
}
if (image.dimensions && image.dimensions.width && image.dimensions.height) {
dimensions = `${image.dimensions.width} × ${image.dimensions.height}`;
} else if (image.width && image.height) {
dimensions = `${image.width} × ${image.height}`;
}
return `
<div class="gallery__item" data-image-id="${image.ulid}">
<div class="gallery__item-inner">
<div class="gallery__item-image">
<img src="${image.url}" alt="${image.alt_text || image.filename}" loading="lazy">
<div class="gallery__item-overlay">
<div class="gallery__item-actions">
<button type="button" class="action-btn" title="View Details">👁</button>
<button type="button" class="action-btn" title="Edit">✏️</button>
<button type="button" class="action-btn" title="Delete">🗑</button>
</div>
</div>
</div>
<div class="gallery__item-info">
<div class="gallery__item-name" title="${image.original_filename || image.filename}">
${image.original_filename || image.filename}
</div>
<div class="gallery__item-meta">
<span class="meta-item">${dimensions}</span>
<span class="meta-item">${fileSize}</span>
<span class="meta-item">${(image.mime_type || '').replace('image/', '')}</span>
</div>
</div>
</div>
</div>
`;
}
showImageModal(image) {
if (window.ImageModal) {
window.ImageModal.show(image);
}
}
formatFileSize(bytes) {
const sizes = ['B', 'KB', 'MB', 'GB'];
if (bytes === 0) return '0 B';
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${Math.round(bytes / Math.pow(1024, i) * 100) / 100} ${sizes[i]}`;
}
showLoading() {
const loading = this.element.querySelector('.gallery__loading');
if (loading) loading.style.display = 'flex';
}
hideLoading() {
const loading = this.element.querySelector('.gallery__loading');
if (loading) loading.style.display = 'none';
}
showError(message) {
const grid = this.element.querySelector('.gallery__grid');
grid.innerHTML = `
<div class="gallery__error">
<div class="error-message">
<span>⚠️</span>
<span>${message}</span>
<button type="button" class="error-close" onclick="this.parentElement.parentElement.remove()">&times;</button>
</div>
</div>
`;
}
updateLoadMoreButton(metaInfo) {
const loadMoreBtn = this.element.querySelector('.gallery__load-more');
const hasMore = metaInfo.has_more || metaInfo.hasMore || metaInfo.pagination?.hasMore || false;
if (hasMore) {
loadMoreBtn.style.display = 'block';
loadMoreBtn.disabled = false;
} else {
loadMoreBtn.style.display = 'none';
}
}
async loadMoreImages() {
this.currentPage++;
await this.loadImages();
}
searchImages(query) {
// Simple client-side search for now
// TODO: Implement server-side search
const filteredImages = this.images.filter(image =>
(image.filename || '').toLowerCase().includes(query.toLowerCase()) ||
(image.original_filename || '').toLowerCase().includes(query.toLowerCase()) ||
(image.alt_text || '').toLowerCase().includes(query.toLowerCase())
);
const grid = this.element.querySelector('.gallery__grid');
grid.innerHTML = filteredImages.map(image => this.renderImageItem(image)).join('');
}
sortImages(sortType) {
const sortedImages = [...this.images];
switch (sortType) {
case 'created_desc':
sortedImages.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
break;
case 'created_asc':
sortedImages.sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
break;
case 'name_asc':
sortedImages.sort((a, b) => (a.filename || '').localeCompare(b.filename || ''));
break;
case 'name_desc':
sortedImages.sort((a, b) => (b.filename || '').localeCompare(a.filename || ''));
break;
}
this.images = sortedImages;
this.renderImages();
}
destroy() {
// Clean up event listeners and DOM
this.element.innerHTML = '';
Logger.info('[ImageGallery] Gallery destroyed');
}
}
// Placeholder classes for uploader and modal
class ImageUploader {
constructor(element, config) {
this.element = element;
this.config = config;
Logger.info('[ImageUploader] Uploader placeholder initialized');
}
destroy() {
Logger.info('[ImageUploader] Uploader destroyed');
}
}
class ImageModal {
constructor() {
Logger.info('[ImageModal] Modal placeholder initialized');
}
show(image) {
Logger.info('[ImageModal] Would show modal for image:', image);
}
destroy() {
Logger.info('[ImageModal] Modal destroyed');
}
}
// Export for module system - following exact pattern from working modules
export const init = ImageManagerModule.init.bind(ImageManagerModule);
export const destroy = ImageManagerModule.destroy.bind(ImageManagerModule);
export default ImageManagerModule;

View File

@@ -22,7 +22,7 @@ export async function registerModules() {
);
// Always load these core modules
const coreModules = new Set(['spa-router', 'form-handling', 'api-manager']);
const coreModules = new Set(['spa-router', 'form-handling', 'api-manager', 'image-manager']);
const usedModules = new Set([...domModules, ...coreModules]);
const fallbackMode = usedModules.size === coreModules.size && domModules.size === 0;
@@ -33,16 +33,26 @@ export async function registerModules() {
Logger.info('[Modules] Fallback mode:', fallbackMode);
// Phase 1: Register only USED modules with dependency manager
console.log('🔍 [Modules] Starting Phase 1 - Module Registration');
console.log('🔍 [Modules] All discovered modules:', Object.keys(modules));
console.log('🔍 [Modules] Used modules:', [...usedModules]);
console.log('🔍 [Modules] Fallback mode:', fallbackMode);
Object.entries(modules).forEach(([path, mod]) => {
const name = path.split('/').slice(-2, -1)[0];
console.log(`🔍 [Module] Processing: ${name} from ${path}`);
console.log(`🔍 [Module] Used modules has ${name}:`, usedModules.has(name));
if(!fallbackMode && !usedModules.has(name)) {
Logger.info(`⏭️ [Module] Skipping unused module: ${name}`);
console.log(`⏭️ [Module] Skipping unused module: ${name}`);
return;
}
console.log(`✅ [Module] Registering ${name} with dependency manager`);
// Register module definition if provided
if (typeof mod.definition === 'object') {
Logger.info(`📋 [Module] Using definition for ${name}:`, mod.definition);
dependencyManager.register(mod.definition);
} else {
// Create default definition for modules without explicit dependencies
@@ -53,59 +63,77 @@ export async function registerModules() {
provides: [],
priority: 0
};
Logger.info(`📋 [Module] Using default definition for ${name}:`, defaultDef);
dependencyManager.register(defaultDef);
}
});
// Phase 2: Calculate initialization order
const initOrder = dependencyManager.calculateInitializationOrder();
console.log('🔍 [Modules] Phase 2 - Initialization Order:', initOrder);
// Phase 3: Initialize modules in dependency order
for (const name of initOrder) {
console.log(`🔍 [Module] Phase 3 - Processing ${name} for initialization`);
console.log(`🔍 [Module] Used modules has ${name}:`, usedModules.has(name));
console.log(`🔍 [Module] Fallback mode:`, fallbackMode);
if(!fallbackMode && !usedModules.has(name)) {
Logger.info(`⏭️ [Module] Skipped (not used in DOM): ${name}`);
console.log(`⏭️ [Module] Skipped (not used in DOM): ${name}`);
continue;
}
const modulePath = Object.keys(modules).find(path =>
const modulePath = Object.keys(modules).find(path =>
path.split('/').slice(-2, -1)[0] === name
);
console.log(`🔍 [Module] Looking for module path for ${name}, found:`, modulePath);
if (!modulePath) {
Logger.warn(`⛔ [Module] No implementation found for: ${name}`);
console.log(`⛔ [Module] No implementation found for: ${name}`);
continue;
}
const mod = modules[modulePath];
const config = moduleConfig[name] || {};
console.log(`🔍 [Module] Module ${name} object:`, mod);
console.log(`🔍 [Module] Config for ${name}:`, config);
// Check dependencies before initialization
const depCheck = dependencyManager.checkDependencies(name);
console.log(`🔍 [Module] Dependency check for ${name}:`, depCheck);
if (!depCheck.satisfied) {
Logger.error(`❌ [Module] Cannot initialize ${name}: ${depCheck.reason}`);
console.log(`❌ [Module] Cannot initialize ${name}: ${depCheck.reason}`);
activeModules.set(name, { mod: null, config, error: new Error(depCheck.reason), original: mod });
continue;
}
console.log(`🔍 [Module] Checking init function for ${name}:`, typeof mod.init, mod.init);
console.log(`🔍 [Module] Module object keys for ${name}:`, Object.keys(mod));
console.log(`🔍 [Module] Full module object for ${name}:`, mod);
if (typeof mod.init === 'function') {
try {
Logger.info(`🚀 [Module] Starting initialization for ${name}`);
dependencyManager.markInitializing(name);
// Create scoped state manager for module
const scopedState = stateManager.createScope(name);
// Wrap module with error boundary
const protectedMod = moduleErrorBoundary.wrapModule(mod, name);
// Initialize module with config and state
await protectedMod.init(config, scopedState);
dependencyManager.markInitialized(name);
activeModules.set(name, {
mod: protectedMod,
config,
activeModules.set(name, {
mod: protectedMod,
config,
state: scopedState,
original: mod
original: mod
});
Logger.info(`✅ [Module] Initialized: ${name}`);
} catch (error) {
@@ -113,7 +141,8 @@ export async function registerModules() {
activeModules.set(name, { mod: null, config, error, original: mod });
}
} else {
Logger.warn(`⛔ [Module] No init() in ${name}`);
Logger.warn(`⛔ [Module] No init() in ${name} - typeof:`, typeof mod.init);
Logger.warn(`⛔ [Module] Available properties:`, Object.getOwnPropertyNames(mod));
}
}
@@ -175,4 +204,5 @@ export function getModuleHealth() {
// Debug function - access via console
if (typeof window !== 'undefined') {
window.moduleHealth = getModuleHealth;
window.activeModules = activeModules;
}

152
resources/js/test-upload.js Normal file
View File

@@ -0,0 +1,152 @@
/**
* Test script for JavaScript upload functionality
*/
import uploadManager, { FileValidator } from './utils/upload.js';
// Wait for DOM to be ready
document.addEventListener('DOMContentLoaded', () => {
const fileInput = document.getElementById('test-upload');
const uploadButton = document.getElementById('upload-button');
const progressBar = document.getElementById('progress-bar');
const progressText = document.getElementById('progress-text');
const results = document.getElementById('results');
if (!fileInput || !uploadButton) {
console.log('Upload test elements not found on this page');
return;
}
uploadButton.addEventListener('click', async () => {
const files = Array.from(fileInput.files);
if (files.length === 0) {
showMessage('Please select at least one file', 'error');
return;
}
// Validate files
for (const file of files) {
const errors = FileValidator.validateImage(file);
if (errors.length > 0) {
showMessage(`File ${file.name}: ${errors.join(', ')}`, 'error');
return;
}
}
try {
uploadButton.disabled = true;
showMessage('Starting upload...', 'info');
if (files.length === 1) {
// Single file upload
await uploadSingleFile(files[0]);
} else {
// Multiple file upload
await uploadMultipleFiles(files);
}
} catch (error) {
showMessage(`Upload failed: ${error.message}`, 'error');
} finally {
uploadButton.disabled = false;
progressBar.style.width = '0%';
progressText.textContent = '';
}
});
async function uploadSingleFile(file) {
const result = await uploadManager.uploadImage(file, {
altText: `Uploaded image: ${file.name}`,
onProgress: (percent, loaded, total) => {
updateProgress(percent, `Uploading ${file.name}...`);
}
});
showUploadResult(result, file.name);
}
async function uploadMultipleFiles(files) {
const results = await uploadManager.uploadMultipleImages(files, {
onProgress: (overallPercent, currentIndex, totalFiles) => {
updateProgress(overallPercent, `Uploading file ${currentIndex + 1} of ${totalFiles}...`);
},
onFileComplete: (result, index, total) => {
showMessage(`Completed ${index + 1}/${total}: ${files[index].name}`, 'success');
}
});
showMessage(`Upload completed. ${results.filter(r => r.success).length}/${results.length} files successful`, 'info');
results.forEach(result => {
showUploadResult(result.success ? result.data : null, result.file, result.error);
});
}
function updateProgress(percent, text) {
progressBar.style.width = `${percent}%`;
progressText.textContent = `${text} (${Math.round(percent)}%)`;
}
function showMessage(message, type = 'info') {
const messageDiv = document.createElement('div');
messageDiv.className = `message message-${type}`;
messageDiv.textContent = message;
results.insertBefore(messageDiv, results.firstChild);
// Remove after 5 seconds
setTimeout(() => {
if (messageDiv.parentNode) {
messageDiv.remove();
}
}, 5000);
}
function showUploadResult(uploadData, fileName, error = null) {
const resultDiv = document.createElement('div');
resultDiv.className = 'upload-result';
if (error) {
resultDiv.innerHTML = `
<h4>❌ ${fileName}</h4>
<p class="error">Error: ${error}</p>
`;
} else {
resultDiv.innerHTML = `
<h4>✅ ${fileName}</h4>
<div class="result-details">
<p><strong>ULID:</strong> ${uploadData.ulid}</p>
<p><strong>Filename:</strong> ${uploadData.filename}</p>
<p><strong>Size:</strong> ${uploadData.file_size.human_readable}</p>
<p><strong>Dimensions:</strong> ${uploadData.dimensions.width}x${uploadData.dimensions.height}</p>
<p><strong>MIME Type:</strong> ${uploadData.mime_type}</p>
<img src="${uploadData.thumbnail_url}" alt="${uploadData.alt_text}" style="max-width: 150px; margin-top: 10px;">
</div>
`;
}
results.appendChild(resultDiv);
}
});
// Test CSRF token functionality
async function testCsrfTokens() {
try {
console.log('Testing CSRF token generation...');
const tokens = await uploadManager.getCsrfTokens('/api/images', 'post');
console.log('CSRF tokens received:', {
form_id: tokens.form_id,
token: tokens.token.substring(0, 10) + '...',
headers: tokens.headers
});
return tokens;
} catch (error) {
console.error('CSRF token test failed:', error);
throw error;
}
}
// Make test function available globally for console testing
window.testCsrfTokens = testCsrfTokens;
window.uploadManager = uploadManager;
window.FileValidator = FileValidator;

View File

@@ -1,61 +0,0 @@
/**
* Simple cache implementation with TTL support
*/
export class SimpleCache {
constructor(maxSize = 20, defaultTTL = 60000) {
this.cache = new Map();
this.maxSize = maxSize;
this.ttl = defaultTTL;
}
get(key) {
const entry = this.cache.get(key);
if (!entry) return null;
// Check if entry is expired
if (Date.now() - entry.timestamp > this.ttl) {
this.cache.delete(key);
return null;
}
return entry;
}
set(key, value) {
// Remove oldest entries if cache is full
if (this.cache.size >= this.maxSize) {
const oldestKey = [...this.cache.entries()]
.sort((a, b) => a[1].timestamp - b[1].timestamp)[0][0];
this.cache.delete(oldestKey);
}
this.cache.set(key, value);
}
has(key) {
const entry = this.get(key);
return entry !== null;
}
delete(key) {
return this.cache.delete(key);
}
clear() {
this.cache.clear();
}
size() {
return this.cache.size;
}
// Clean up expired entries
cleanup() {
const now = Date.now();
for (const [key, value] of this.cache.entries()) {
if (now - value.timestamp > this.ttl) {
this.cache.delete(key);
}
}
}
}

View File

@@ -0,0 +1,242 @@
/**
* Upload utility with CSRF support for the Custom PHP Framework
*/
export class UploadManager {
constructor(baseUrl = '') {
this.baseUrl = baseUrl;
this.csrfCache = new Map();
}
/**
* Get CSRF tokens for a form
* @param {string} action - Form action URL
* @param {string} method - Form method (default: 'post')
* @returns {Promise<{form_id: string, token: string, headers: Object}>}
*/
async getCsrfTokens(action = '/', method = 'post') {
const cacheKey = `${action}:${method}`;
if (this.csrfCache.has(cacheKey)) {
return this.csrfCache.get(cacheKey);
}
try {
const response = await fetch(`${this.baseUrl}/api/csrf/token?action=${encodeURIComponent(action)}&method=${encodeURIComponent(method)}`, {
method: 'GET',
headers: {
'Accept': 'application/json',
'User-Agent': navigator.userAgent
}
});
if (!response.ok) {
throw new Error(`CSRF token request failed: ${response.status}`);
}
const tokens = await response.json();
// Cache tokens for reuse
this.csrfCache.set(cacheKey, tokens);
return tokens;
} catch (error) {
console.error('Failed to get CSRF tokens:', error);
throw error;
}
}
/**
* Upload an image file with CSRF protection
* @param {File} file - The image file to upload
* @param {Object} options - Upload options
* @param {string} options.altText - Alt text for the image
* @param {Function} options.onProgress - Progress callback
* @param {AbortSignal} options.signal - Abort signal for cancellation
* @returns {Promise<Object>} - Upload result
*/
async uploadImage(file, options = {}) {
const { altText = '', onProgress, signal } = options;
try {
// Get CSRF tokens
const csrfTokens = await this.getCsrfTokens('/api/images', 'post');
// Note: Honeypot validation is disabled for API routes
// API routes use CSRF tokens for protection instead
// Validate file
if (!file || !(file instanceof File)) {
throw new Error('Invalid file provided');
}
if (!file.type.startsWith('image/')) {
throw new Error('File must be an image');
}
// Create FormData
const formData = new FormData();
formData.append('image', file);
if (altText) {
formData.append('alt_text', altText);
}
// Note: No honeypot fields needed for API routes
// API routes are protected by CSRF tokens in headers
// Create upload request
const xhr = new XMLHttpRequest();
return new Promise((resolve, reject) => {
// Handle progress
if (onProgress) {
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable) {
const percentComplete = (event.loaded / event.total) * 100;
onProgress(percentComplete, event.loaded, event.total);
}
});
}
// Handle completion
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
try {
const result = JSON.parse(xhr.responseText);
resolve(result);
} catch (parseError) {
reject(new Error('Invalid JSON response'));
}
} else {
try {
const errorData = JSON.parse(xhr.responseText);
reject(new Error(errorData.message || `Upload failed with status ${xhr.status}`));
} catch {
reject(new Error(`Upload failed with status ${xhr.status}`));
}
}
});
// Handle errors
xhr.addEventListener('error', () => {
reject(new Error('Network error during upload'));
});
xhr.addEventListener('abort', () => {
reject(new Error('Upload was cancelled'));
});
// Handle cancellation
if (signal) {
signal.addEventListener('abort', () => {
xhr.abort();
});
}
// Open and send request
xhr.open('POST', `${this.baseUrl}/api/images`);
// Set CSRF headers
xhr.setRequestHeader('X-CSRF-Form-ID', csrfTokens.form_id);
xhr.setRequestHeader('X-CSRF-Token', csrfTokens.token);
xhr.setRequestHeader('Accept', 'application/json');
xhr.setRequestHeader('User-Agent', navigator.userAgent);
xhr.send(formData);
});
} catch (error) {
console.error('Upload failed:', error);
throw error;
}
}
/**
* Upload multiple images with progress tracking
* @param {File[]} files - Array of image files
* @param {Object} options - Upload options
* @returns {Promise<Object[]>} - Array of upload results
*/
async uploadMultipleImages(files, options = {}) {
const { onProgress, onFileComplete, signal } = options;
const results = [];
let completedCount = 0;
for (const [index, file] of files.entries()) {
try {
const fileOptions = {
...options,
onProgress: (percent, loaded, total) => {
if (onProgress) {
const overallProgress = ((completedCount + (percent / 100)) / files.length) * 100;
onProgress(overallProgress, index, files.length);
}
},
signal
};
const result = await this.uploadImage(file, fileOptions);
results.push({ success: true, file: file.name, data: result });
completedCount++;
if (onFileComplete) {
onFileComplete(result, index, files.length);
}
} catch (error) {
results.push({ success: false, file: file.name, error: error.message });
completedCount++;
}
}
return results;
}
/**
* Clear CSRF token cache
*/
clearCsrfCache() {
this.csrfCache.clear();
}
}
/**
* File validation utilities
*/
export class FileValidator {
static MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
static ALLOWED_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'];
static validateImage(file) {
const errors = [];
if (!file || !(file instanceof File)) {
errors.push('Invalid file');
return errors;
}
// Check file size
if (file.size > this.MAX_FILE_SIZE) {
errors.push(`File size must be less than ${this.MAX_FILE_SIZE / (1024 * 1024)}MB`);
}
// Check file type
if (!this.ALLOWED_TYPES.includes(file.type)) {
errors.push(`File type ${file.type} is not allowed. Allowed types: ${this.ALLOWED_TYPES.join(', ')}`);
}
// Check file name
if (file.name.length > 255) {
errors.push('File name is too long (max 255 characters)');
}
return errors;
}
static isValidImage(file) {
return this.validateImage(file).length === 0;
}
}
// Export a default instance
export default new UploadManager();