mirror of
https://github.com/R0m1k3/noteflow.git
synced 2026-10-23 07:08:17 +02:00
Création complète de l'application NoteFlow - Notes & Todo List
Application web Dockerisée moderne avec: - Backend Node.js + Express + SQLite - Frontend HTML/CSS/JavaScript vanilla - Authentification JWT + bcrypt - Gestion des notes avec images et todos intégrés - Sidebar todos globaux avec filtres - Interface utilisateur fluide avec animations CSS - Administration des utilisateurs - Sécurité production-ready (Helmet, rate limiting, validation) - Configuration Docker complète - Documentation README détaillée Stack technique: - Node.js 20 Alpine - SQLite3 avec indexes optimisés - Multer pour upload d'images - Winston pour logging - Express-validator pour validation - Architecture modulaire (routes, middleware, config) Fonctionnalités: - Notes: CRUD complet, recherche, images, todos intégrés - Todos globaux: création, toggle, filtres, suppression - Auth: JWT 24h, bcrypt 12 rounds - Admin: gestion utilisateurs complète - UI: masonry grid, modal animations, responsive Configuration Docker: - Healthcheck intégré - Volumes persistants pour data et uploads - Network nginx_default pour reverse proxy - Variables d'environnement configurables Prêt pour déploiement avec: docker-compose up -d
This commit is contained in:
22 files changed
+3414
-589
No files matched your search
@@ -0,0 +1,126 @@
|
||||
/* Animations CSS */
|
||||
|
||||
/* Fade in */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Backdrop fade */
|
||||
@keyframes backdropFade {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Modal open animation */
|
||||
@keyframes modalOpen {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.85) translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Slide in from bottom */
|
||||
@keyframes slideInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Slide in from right */
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Card hover animation */
|
||||
@keyframes cardHover {
|
||||
from {
|
||||
transform: translateY(0);
|
||||
}
|
||||
to {
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
}
|
||||
|
||||
/* Pulse animation */
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
/* Spin animation */
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Application des animations */
|
||||
.modal-backdrop {
|
||||
animation: backdropFade var(--transition-slow) ease;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
animation: modalOpen 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
.note-card {
|
||||
animation: slideInUp 0.3s ease;
|
||||
}
|
||||
|
||||
.todo-item {
|
||||
animation: slideInRight 0.2s ease;
|
||||
}
|
||||
|
||||
.user-dropdown {
|
||||
animation: slideInUp 0.2s ease;
|
||||
}
|
||||
|
||||
/* Loading state */
|
||||
.loading {
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.spinning {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
/* Smooth transitions */
|
||||
.smooth-transition {
|
||||
transition: all var(--transition-base);
|
||||
}
|
||||
|
||||
.smooth-transition-slow {
|
||||
transition: all var(--transition-slow);
|
||||
}
|
||||
@@ -0,0 +1,510 @@
|
||||
/* Composants UI */
|
||||
|
||||
/* Search input */
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
padding-right: 40px;
|
||||
border: 2px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
font-size: var(--font-size-base);
|
||||
transition: border-color var(--transition-base);
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.search-clear {
|
||||
position: absolute;
|
||||
right: var(--spacing-sm);
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-border);
|
||||
color: var(--color-text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-base);
|
||||
}
|
||||
|
||||
.search-clear:hover {
|
||||
background: var(--color-text-secondary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* User dropdown */
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-base);
|
||||
}
|
||||
|
||||
.user-info:hover {
|
||||
background: var(--color-background);
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
font-size: var(--font-size-xl);
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.user-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + var(--spacing-sm));
|
||||
right: 0;
|
||||
background: white;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-lg);
|
||||
min-width: 200px;
|
||||
display: none;
|
||||
animation: fadeIn var(--transition-base);
|
||||
}
|
||||
|
||||
.user-dropdown.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
width: 100%;
|
||||
padding: var(--spacing-md);
|
||||
text-align: left;
|
||||
transition: background var(--transition-base);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.dropdown-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.dropdown-item:hover {
|
||||
background: var(--color-background);
|
||||
}
|
||||
|
||||
/* Note cards */
|
||||
.note-card {
|
||||
background: var(--color-card);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--spacing-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
cursor: pointer;
|
||||
transition: transform var(--transition-base), box-shadow var(--transition-base);
|
||||
position: relative;
|
||||
min-height: 150px;
|
||||
max-height: 400px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.note-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.note-card-title {
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: 600;
|
||||
margin-bottom: var(--spacing-sm);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.note-card-content {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: 1.6;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.note-card-image {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: var(--radius-sm);
|
||||
object-fit: cover;
|
||||
position: absolute;
|
||||
top: var(--spacing-md);
|
||||
right: var(--spacing-md);
|
||||
}
|
||||
|
||||
.note-card-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-secondary);
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.note-card-badge {
|
||||
background: var(--color-accent);
|
||||
color: white;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
/* FAB Button */
|
||||
.fab {
|
||||
position: fixed;
|
||||
bottom: var(--spacing-xl);
|
||||
right: calc(var(--sidebar-width) + var(--spacing-xl));
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
background: var(--color-accent);
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
font-size: 32px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
transition: transform var(--transition-base), box-shadow var(--transition-base);
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.fab:hover {
|
||||
transform: scale(1.1);
|
||||
box-shadow: var(--shadow-xl);
|
||||
background: var(--color-accent-hover);
|
||||
}
|
||||
|
||||
/* Todo sidebar components */
|
||||
.sidebar-title {
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: bold;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.todo-input-container {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.todo-input {
|
||||
flex: 1;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border: 2px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--font-size-base);
|
||||
}
|
||||
|
||||
.todo-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.todo-add-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: var(--color-accent);
|
||||
color: white;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--font-size-xl);
|
||||
transition: background var(--transition-base);
|
||||
}
|
||||
|
||||
.todo-add-btn:hover {
|
||||
background: var(--color-accent-hover);
|
||||
}
|
||||
|
||||
.todo-filters {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
flex: 1;
|
||||
padding: var(--spacing-sm);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--font-size-sm);
|
||||
transition: background var(--transition-base), color var(--transition-base);
|
||||
}
|
||||
|
||||
.filter-btn.active {
|
||||
background: var(--color-accent);
|
||||
color: white;
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.todo-counter {
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.todo-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.todo-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-md);
|
||||
background: white;
|
||||
border-radius: var(--radius-md);
|
||||
transition: background var(--transition-base);
|
||||
}
|
||||
|
||||
.todo-item:hover {
|
||||
background: var(--color-background);
|
||||
}
|
||||
|
||||
.todo-item.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.todo-checkbox {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.todo-text {
|
||||
flex: 1;
|
||||
font-size: var(--font-size-base);
|
||||
}
|
||||
|
||||
.todo-item.completed .todo-text {
|
||||
text-decoration: line-through;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.todo-delete {
|
||||
opacity: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-danger);
|
||||
color: white;
|
||||
font-size: var(--font-size-sm);
|
||||
transition: opacity var(--transition-base);
|
||||
}
|
||||
|
||||
.todo-item:hover .todo-delete {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(8px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: white;
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: var(--shadow-xl);
|
||||
max-width: 700px;
|
||||
width: 100%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
padding: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.admin-modal {
|
||||
max-width: 900px;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-border);
|
||||
font-size: var(--font-size-lg);
|
||||
transition: background var(--transition-base);
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
background: var(--color-text-secondary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.modal-delete {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-danger);
|
||||
color: white;
|
||||
font-size: var(--font-size-lg);
|
||||
transition: background var(--transition-base);
|
||||
}
|
||||
|
||||
.modal-delete:hover {
|
||||
background: #DC2626;
|
||||
}
|
||||
|
||||
.note-title-input {
|
||||
width: 100%;
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: bold;
|
||||
border: none;
|
||||
padding: var(--spacing-sm) 0;
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.note-title-input:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.note-divider {
|
||||
height: 1px;
|
||||
background: var(--color-border);
|
||||
margin: var(--spacing-lg) 0;
|
||||
}
|
||||
|
||||
.note-content-textarea {
|
||||
width: 100%;
|
||||
min-height: 200px;
|
||||
border: none;
|
||||
resize: vertical;
|
||||
font-size: var(--font-size-base);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.note-content-textarea:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.note-image-container {
|
||||
position: relative;
|
||||
margin: var(--spacing-lg) 0;
|
||||
}
|
||||
|
||||
.note-image {
|
||||
width: 100%;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.note-image-remove {
|
||||
position: absolute;
|
||||
top: var(--spacing-sm);
|
||||
right: var(--spacing-sm);
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-danger);
|
||||
color: white;
|
||||
font-size: var(--font-size-base);
|
||||
}
|
||||
|
||||
.note-section-title {
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: 600;
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.note-todos-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.note-add-todo-btn {
|
||||
width: 100%;
|
||||
padding: var(--spacing-md);
|
||||
border: 2px dashed var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-text-secondary);
|
||||
transition: border-color var(--transition-base), color var(--transition-base);
|
||||
}
|
||||
|
||||
.note-add-todo-btn:hover {
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-md);
|
||||
margin-top: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
background: var(--color-accent);
|
||||
color: white;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--font-size-base);
|
||||
transition: background var(--transition-base);
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: var(--color-accent-hover);
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
margin-top: var(--spacing-lg);
|
||||
padding-top: var(--spacing-lg);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.note-metadata {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* Admin table */
|
||||
.admin-actions {
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.users-table-container {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.users-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.users-table th,
|
||||
.users-table td {
|
||||
padding: var(--spacing-md);
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.users-table th {
|
||||
font-weight: 600;
|
||||
background: var(--color-background);
|
||||
}
|
||||
|
||||
.users-table tbody tr:hover {
|
||||
background: var(--color-background);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
/* Layout principal de l'application */
|
||||
|
||||
body {
|
||||
font-family: var(--font-family);
|
||||
background: var(--color-background);
|
||||
color: var(--color-text-primary);
|
||||
font-size: var(--font-size-base);
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: var(--header-height);
|
||||
background: var(--color-card);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
z-index: 100;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.header-content {
|
||||
height: 100%;
|
||||
max-width: 100%;
|
||||
padding: 0 var(--spacing-lg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.header-logo h1 {
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: bold;
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.header-search {
|
||||
flex: 1;
|
||||
max-width: 600px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.header-user {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Main container */
|
||||
.main-container {
|
||||
display: flex;
|
||||
margin-top: var(--header-height);
|
||||
min-height: calc(100vh - var(--header-height));
|
||||
}
|
||||
|
||||
/* Notes area */
|
||||
.notes-area {
|
||||
flex: 1;
|
||||
padding: var(--spacing-xl);
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.notes-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(var(--note-card-width), 1fr));
|
||||
gap: var(--spacing-lg);
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Todo sidebar */
|
||||
.todo-sidebar {
|
||||
position: sticky;
|
||||
top: var(--header-height);
|
||||
right: 0;
|
||||
width: var(--sidebar-width);
|
||||
height: calc(100vh - var(--header-height));
|
||||
background: var(--color-sidebar);
|
||||
border-left: 1px solid var(--color-border);
|
||||
padding: var(--spacing-lg);
|
||||
overflow-y: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 1024px) {
|
||||
.notes-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
}
|
||||
|
||||
.todo-sidebar {
|
||||
width: 320px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.main-container {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
padding: 0 var(--spacing-md);
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.header-logo h1 {
|
||||
font-size: var(--font-size-lg);
|
||||
}
|
||||
|
||||
.header-search {
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.notes-area {
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
|
||||
.notes-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.todo-sidebar {
|
||||
position: static;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-height: 400px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/* Reset CSS pour normaliser les styles entre navigateurs */
|
||||
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
img, picture, video, canvas, svg {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
input, button, textarea, select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
p, h1, h2, h3, h4, h5, h6 {
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: none;
|
||||
}
|
||||
|
||||
ul, ol {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/* Variables CSS globales */
|
||||
|
||||
:root {
|
||||
/* Couleurs principales */
|
||||
--color-background: #FAFBFC;
|
||||
--color-card: #FFFFFF;
|
||||
--color-sidebar: #F8F9FA;
|
||||
--color-accent: #3B82F6;
|
||||
--color-accent-hover: #2563EB;
|
||||
--color-text-primary: #1F2937;
|
||||
--color-text-secondary: #6B7280;
|
||||
--color-border: #E5E7EB;
|
||||
--color-success: #10B981;
|
||||
--color-danger: #EF4444;
|
||||
|
||||
/* Typographie */
|
||||
--font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
--font-size-xs: 12px;
|
||||
--font-size-sm: 14px;
|
||||
--font-size-base: 16px;
|
||||
--font-size-lg: 18px;
|
||||
--font-size-xl: 24px;
|
||||
--font-size-2xl: 32px;
|
||||
|
||||
/* Spacing */
|
||||
--spacing-xs: 4px;
|
||||
--spacing-sm: 8px;
|
||||
--spacing-md: 16px;
|
||||
--spacing-lg: 24px;
|
||||
--spacing-xl: 32px;
|
||||
|
||||
/* Border radius */
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 12px;
|
||||
--radius-xl: 16px;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||
--shadow-xl: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
|
||||
/* Transitions */
|
||||
--transition-fast: 0.15s ease;
|
||||
--transition-base: 0.2s ease;
|
||||
--transition-slow: 0.3s ease;
|
||||
|
||||
/* Dimensions */
|
||||
--header-height: 60px;
|
||||
--sidebar-width: 400px;
|
||||
--note-card-width: 350px;
|
||||
}
|
||||
+143
-7
@@ -3,14 +3,150 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Notes & Todos</title>
|
||||
<link rel="stylesheet" href="/css/styles.css">
|
||||
<title>NoteFlow - Notes & Todo</title>
|
||||
|
||||
<!-- CSS -->
|
||||
<link rel="stylesheet" href="/css/reset.css">
|
||||
<link rel="stylesheet" href="/css/variables.css">
|
||||
<link rel="stylesheet" href="/css/layout.css">
|
||||
<link rel="stylesheet" href="/css/components.css">
|
||||
<link rel="stylesheet" href="/css/animations.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script src="/js/auth.js"></script>
|
||||
<script src="/js/notes.js"></script>
|
||||
<script src="/js/admin.js"></script>
|
||||
<script src="/js/app.js"></script>
|
||||
<!-- Header fixe -->
|
||||
<header class="header">
|
||||
<div class="header-content">
|
||||
<div class="header-logo">
|
||||
<h1>📝 NoteFlow</h1>
|
||||
</div>
|
||||
|
||||
<div class="header-search">
|
||||
<input type="text" id="searchInput" placeholder="Rechercher des notes..." class="search-input">
|
||||
<button id="searchClear" class="search-clear" style="display: none;">✕</button>
|
||||
</div>
|
||||
|
||||
<div class="header-user">
|
||||
<div class="user-info" id="userInfo">
|
||||
<span class="user-avatar">👤</span>
|
||||
<span class="user-name" id="userName">User</span>
|
||||
<span class="user-dropdown-icon">▼</span>
|
||||
</div>
|
||||
<div class="user-dropdown" id="userDropdown">
|
||||
<button class="dropdown-item" id="adminBtn" style="display: none;">⚙️ Administration</button>
|
||||
<button class="dropdown-item" id="logoutBtn">🚪 Déconnexion</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Container principal -->
|
||||
<div class="main-container">
|
||||
<!-- Zone des notes (70%) -->
|
||||
<main class="notes-area">
|
||||
<div class="notes-grid" id="notesGrid">
|
||||
<!-- Les cards de notes seront insérées ici dynamiquement -->
|
||||
</div>
|
||||
|
||||
<button class="fab" id="newNoteBtn" title="Nouvelle note">+</button>
|
||||
</main>
|
||||
|
||||
<!-- Sidebar Todo (30%) -->
|
||||
<aside class="todo-sidebar">
|
||||
<h2 class="sidebar-title">Quick Tasks</h2>
|
||||
|
||||
<div class="todo-input-container">
|
||||
<input type="text" id="todoInput" class="todo-input" placeholder="Ajouter une tâche...">
|
||||
<button id="addTodoBtn" class="todo-add-btn">+</button>
|
||||
</div>
|
||||
|
||||
<div class="todo-filters">
|
||||
<button class="filter-btn active" data-filter="all">Toutes</button>
|
||||
<button class="filter-btn" data-filter="active">Actives</button>
|
||||
<button class="filter-btn" data-filter="completed">Terminées</button>
|
||||
</div>
|
||||
|
||||
<div class="todo-counter">
|
||||
<span id="todoCounter">0 tâches restantes</span>
|
||||
</div>
|
||||
|
||||
<div class="todo-list" id="todoList">
|
||||
<!-- Les todos seront insérées ici dynamiquement -->
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<!-- Modal Note -->
|
||||
<div class="modal-backdrop" id="noteModal" style="display: none;">
|
||||
<div class="modal-content note-modal">
|
||||
<div class="modal-header">
|
||||
<button class="modal-close" id="closeModal">✕</button>
|
||||
<button class="modal-delete" id="deleteNote">🗑️</button>
|
||||
</div>
|
||||
|
||||
<input type="text" id="noteTitle" class="note-title-input" placeholder="Titre de la note">
|
||||
|
||||
<div class="note-divider"></div>
|
||||
|
||||
<textarea id="noteContent" class="note-content-textarea" placeholder="Contenu de la note..."></textarea>
|
||||
|
||||
<div id="noteImageContainer" class="note-image-container" style="display: none;">
|
||||
<img id="noteImage" class="note-image" alt="Image de la note">
|
||||
<button class="note-image-remove" id="removeImage">✕</button>
|
||||
</div>
|
||||
|
||||
<div class="note-todos-section">
|
||||
<h3 class="note-section-title">Todos dans cette note</h3>
|
||||
<div id="noteTodosList" class="note-todos-list">
|
||||
<!-- Les todos de la note seront insérés ici -->
|
||||
</div>
|
||||
<button class="note-add-todo-btn" id="addNoteTodo">+ Ajouter un todo</button>
|
||||
</div>
|
||||
|
||||
<div class="note-divider"></div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button class="action-btn" id="addImageBtn">📷 Ajouter une image</button>
|
||||
<input type="file" id="imageInput" accept="image/*" style="display: none;">
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<span id="noteMetadata" class="note-metadata"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal Admin -->
|
||||
<div class="modal-backdrop" id="adminModal" style="display: none;">
|
||||
<div class="modal-content admin-modal">
|
||||
<div class="modal-header">
|
||||
<h2>Administration des utilisateurs</h2>
|
||||
<button class="modal-close" id="closeAdminModal">✕</button>
|
||||
</div>
|
||||
|
||||
<div class="admin-actions">
|
||||
<button class="action-btn" id="createUserBtn">+ Créer un utilisateur</button>
|
||||
</div>
|
||||
|
||||
<div class="users-table-container">
|
||||
<table class="users-table" id="usersTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Nom d'utilisateur</th>
|
||||
<th>Admin</th>
|
||||
<th>Créé le</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="usersTableBody">
|
||||
<!-- Les utilisateurs seront insérés ici -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- JavaScript -->
|
||||
<script src="/js/complete-app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,663 @@
|
||||
// Application NoteFlow - JavaScript complet
|
||||
// Ce fichier regroupe toute la logique de l'application pour simplifier le déploiement
|
||||
|
||||
// ==================== UTILS ====================
|
||||
const utils = {
|
||||
formatDate(dateString) {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diff = now - date;
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (days === 0) return 'Aujourd\'hui';
|
||||
if (days === 1) return 'Hier';
|
||||
if (days < 7) return `Il y a ${days} jours`;
|
||||
|
||||
return date.toLocaleDateString('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined
|
||||
});
|
||||
},
|
||||
|
||||
truncate(text, length = 150) {
|
||||
if (!text || text.length <= length) return text;
|
||||
return text.substring(0, length) + '...';
|
||||
},
|
||||
|
||||
debounce(func, wait) {
|
||||
let timeout;
|
||||
return function executedFunction(...args) {
|
||||
const later = () => {
|
||||
clearTimeout(timeout);
|
||||
func(...args);
|
||||
};
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// ==================== API ====================
|
||||
const api = {
|
||||
async request(url, options = {}) {
|
||||
const token = localStorage.getItem('token');
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
...(token && { 'Authorization': `Bearer ${token}` }),
|
||||
...options.headers
|
||||
};
|
||||
|
||||
const config = {
|
||||
...options,
|
||||
headers
|
||||
};
|
||||
|
||||
const response = await fetch(url, config);
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
window.location.href = '/login.html';
|
||||
throw new Error('Non authentifié');
|
||||
}
|
||||
|
||||
return response;
|
||||
},
|
||||
|
||||
async get(url) {
|
||||
const response = await this.request(url);
|
||||
return response.json();
|
||||
},
|
||||
|
||||
async post(url, data) {
|
||||
const response = await this.request(url, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
return response.json();
|
||||
},
|
||||
|
||||
async put(url, data) {
|
||||
const response = await this.request(url, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
return response.json();
|
||||
},
|
||||
|
||||
async delete(url) {
|
||||
const response = await this.request(url, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
return response.json();
|
||||
},
|
||||
|
||||
async uploadFile(url, formData) {
|
||||
const token = localStorage.getItem('token');
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
window.location.href = '/login.html';
|
||||
throw new Error('Non authentifié');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
};
|
||||
|
||||
// ==================== STATE ====================
|
||||
const state = {
|
||||
notes: [],
|
||||
todos: [],
|
||||
currentNote: null,
|
||||
filter: 'all',
|
||||
searchQuery: '',
|
||||
user: null
|
||||
};
|
||||
|
||||
// ==================== AUTH ====================
|
||||
function checkAuth() {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
window.location.href = '/login.html';
|
||||
return false;
|
||||
}
|
||||
|
||||
const userStr = localStorage.getItem('user');
|
||||
if (userStr) {
|
||||
state.user = JSON.parse(userStr);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function initAuth() {
|
||||
if (!checkAuth()) return;
|
||||
|
||||
// Afficher le nom d'utilisateur
|
||||
const userNameEl = document.getElementById('userName');
|
||||
if (userNameEl && state.user) {
|
||||
userNameEl.textContent = state.user.username;
|
||||
}
|
||||
|
||||
// Afficher le bouton admin si nécessaire
|
||||
const adminBtn = document.getElementById('adminBtn');
|
||||
if (adminBtn && state.user && state.user.is_admin) {
|
||||
adminBtn.style.display = 'block';
|
||||
}
|
||||
|
||||
// Gérer la déconnexion
|
||||
const logoutBtn = document.getElementById('logoutBtn');
|
||||
if (logoutBtn) {
|
||||
logoutBtn.addEventListener('click', async () => {
|
||||
try {
|
||||
await api.post('/api/auth/logout', {});
|
||||
} catch (e) {}
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
window.location.href = '/login.html';
|
||||
});
|
||||
}
|
||||
|
||||
// Toggle user dropdown
|
||||
const userInfo = document.getElementById('userInfo');
|
||||
const userDropdown = document.getElementById('userDropdown');
|
||||
if (userInfo && userDropdown) {
|
||||
userInfo.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
userDropdown.classList.toggle('show');
|
||||
});
|
||||
|
||||
document.addEventListener('click', () => {
|
||||
userDropdown.classList.remove('show');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== NOTES ====================
|
||||
async function loadNotes() {
|
||||
try {
|
||||
state.notes = await api.get('/api/notes');
|
||||
renderNotes();
|
||||
} catch (error) {
|
||||
console.error('Erreur chargement notes:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function renderNotes() {
|
||||
const notesGrid = document.getElementById('notesGrid');
|
||||
if (!notesGrid) return;
|
||||
|
||||
let filtered = state.notes;
|
||||
|
||||
// Filtre de recherche
|
||||
if (state.searchQuery) {
|
||||
const query = state.searchQuery.toLowerCase();
|
||||
filtered = filtered.filter(note =>
|
||||
note.title.toLowerCase().includes(query) ||
|
||||
(note.content && note.content.toLowerCase().includes(query))
|
||||
);
|
||||
}
|
||||
|
||||
notesGrid.innerHTML = '';
|
||||
|
||||
if (filtered.length === 0) {
|
||||
notesGrid.innerHTML = '<p style="grid-column: 1/-1; text-align: center; color: var(--color-text-secondary); padding: 40px;">Aucune note trouvée</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
filtered.forEach(note => {
|
||||
const card = createNoteCard(note);
|
||||
notesGrid.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
function createNoteCard(note) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'note-card';
|
||||
card.onclick = () => openNoteModal(note.id);
|
||||
|
||||
let html = `<div class="note-card-title">${escapeHtml(note.title)}</div>`;
|
||||
|
||||
if (note.content) {
|
||||
html += `<div class="note-card-content">${escapeHtml(utils.truncate(note.content))}</div>`;
|
||||
}
|
||||
|
||||
if (note.image_filename) {
|
||||
html += `<img src="/uploads/${note.image_filename}" class="note-card-image" alt="">`;
|
||||
}
|
||||
|
||||
html += `<div class="note-card-meta">`;
|
||||
html += `<span>${utils.formatDate(note.updated_at)}</span>`;
|
||||
if (note.todos_count > 0) {
|
||||
html += `<span class="note-card-badge">${note.todos_count} tasks</span>`;
|
||||
}
|
||||
html += `</div>`;
|
||||
|
||||
card.innerHTML = html;
|
||||
return card;
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// ==================== MODAL ====================
|
||||
let saveTimeout;
|
||||
let currentNoteId = null;
|
||||
|
||||
async function openNoteModal(noteId = null) {
|
||||
currentNoteId = noteId;
|
||||
const modal = document.getElementById('noteModal');
|
||||
const titleInput = document.getElementById('noteTitle');
|
||||
const contentTextarea = document.getElementById('noteContent');
|
||||
const imageContainer = document.getElementById('noteImageContainer');
|
||||
const noteImage = document.getElementById('noteImage');
|
||||
const noteTodosList = document.getElementById('noteTodosList');
|
||||
const metadata = document.getElementById('noteMetadata');
|
||||
|
||||
if (noteId) {
|
||||
// Charger la note existante
|
||||
try {
|
||||
const note = await api.get(`/api/notes/${noteId}`);
|
||||
state.currentNote = note;
|
||||
|
||||
titleInput.value = note.title;
|
||||
contentTextarea.value = note.content || '';
|
||||
|
||||
if (note.image_filename) {
|
||||
noteImage.src = `/uploads/${note.image_filename}`;
|
||||
imageContainer.style.display = 'block';
|
||||
} else {
|
||||
imageContainer.style.display = 'none';
|
||||
}
|
||||
|
||||
renderNoteTodos(note.todos || []);
|
||||
metadata.textContent = `Créée ${utils.formatDate(note.created_at)} • Modifiée ${utils.formatDate(note.updated_at)}`;
|
||||
} catch (error) {
|
||||
console.error('Erreur chargement note:', error);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Nouvelle note
|
||||
try {
|
||||
const newNote = await api.post('/api/notes', {
|
||||
title: 'Nouvelle note',
|
||||
content: ''
|
||||
});
|
||||
|
||||
currentNoteId = newNote.id;
|
||||
state.currentNote = newNote;
|
||||
|
||||
titleInput.value = newNote.title;
|
||||
contentTextarea.value = '';
|
||||
imageContainer.style.display = 'none';
|
||||
noteTodosList.innerHTML = '';
|
||||
metadata.textContent = 'Nouvelle note';
|
||||
|
||||
// Rafraîchir la liste
|
||||
await loadNotes();
|
||||
} catch (error) {
|
||||
console.error('Erreur création note:', error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
modal.style.display = 'flex';
|
||||
titleInput.focus();
|
||||
titleInput.select();
|
||||
|
||||
// Auto-save sur les modifications
|
||||
setupAutoSave();
|
||||
}
|
||||
|
||||
function setupAutoSave() {
|
||||
const titleInput = document.getElementById('noteTitle');
|
||||
const contentTextarea = document.getElementById('noteContent');
|
||||
|
||||
const saveNote = async () => {
|
||||
if (!currentNoteId) return;
|
||||
|
||||
try {
|
||||
await api.put(`/api/notes/${currentNoteId}`, {
|
||||
title: titleInput.value,
|
||||
content: contentTextarea.value
|
||||
});
|
||||
|
||||
// Rafraîchir la liste
|
||||
await loadNotes();
|
||||
} catch (error) {
|
||||
console.error('Erreur sauvegarde:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const debouncedSave = utils.debounce(saveNote, 1000);
|
||||
|
||||
titleInput.oninput = debouncedSave;
|
||||
contentTextarea.oninput = debouncedSave;
|
||||
}
|
||||
|
||||
function renderNoteTodos(todos) {
|
||||
const list = document.getElementById('noteTodosList');
|
||||
list.innerHTML = '';
|
||||
|
||||
todos.forEach(todo => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'todo-item';
|
||||
item.innerHTML = `
|
||||
<input type="checkbox" class="todo-checkbox" ${todo.completed ? 'checked' : ''}
|
||||
onchange="toggleNoteTodo(${todo.id}, this.checked)">
|
||||
<span class="todo-text">${escapeHtml(todo.text)}</span>
|
||||
<button class="todo-delete" onclick="deleteNoteTodo(${todo.id})">✕</button>
|
||||
`;
|
||||
if (todo.completed) item.classList.add('completed');
|
||||
list.appendChild(item);
|
||||
});
|
||||
}
|
||||
|
||||
async function addNoteTodo() {
|
||||
if (!currentNoteId) return;
|
||||
|
||||
const text = prompt('Texte du todo:');
|
||||
if (!text) return;
|
||||
|
||||
try {
|
||||
await api.post(`/api/notes/${currentNoteId}/todos`, { text });
|
||||
const note = await api.get(`/api/notes/${currentNoteId}`);
|
||||
renderNoteTodos(note.todos || []);
|
||||
await loadNotes();
|
||||
} catch (error) {
|
||||
console.error('Erreur ajout todo:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleNoteTodo(todoId, completed) {
|
||||
try {
|
||||
await api.put(`/api/notes/todos/${todoId}`, { completed });
|
||||
const note = await api.get(`/api/notes/${currentNoteId}`);
|
||||
renderNoteTodos(note.todos || []);
|
||||
await loadNotes();
|
||||
} catch (error) {
|
||||
console.error('Erreur toggle todo:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteNoteTodo(todoId) {
|
||||
if (!confirm('Supprimer ce todo ?')) return;
|
||||
|
||||
try {
|
||||
await api.delete(`/api/notes/todos/${todoId}`);
|
||||
const note = await api.get(`/api/notes/${currentNoteId}`);
|
||||
renderNoteTodos(note.todos || []);
|
||||
await loadNotes();
|
||||
} catch (error) {
|
||||
console.error('Erreur suppression todo:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function closeNoteModal() {
|
||||
document.getElementById('noteModal').style.display = 'none';
|
||||
currentNoteId = null;
|
||||
state.currentNote = null;
|
||||
}
|
||||
|
||||
async function deleteCurrentNote() {
|
||||
if (!currentNoteId) return;
|
||||
if (!confirm('Supprimer cette note ?')) return;
|
||||
|
||||
try {
|
||||
await api.delete(`/api/notes/${currentNoteId}`);
|
||||
closeNoteModal();
|
||||
await loadNotes();
|
||||
} catch (error) {
|
||||
console.error('Erreur suppression note:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function addNoteImage() {
|
||||
document.getElementById('imageInput').click();
|
||||
}
|
||||
|
||||
async function handleImageUpload(event) {
|
||||
const file = event.target.files[0];
|
||||
if (!file || !currentNoteId) return;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('image', file);
|
||||
|
||||
try {
|
||||
const result = await api.uploadFile(`/api/notes/${currentNoteId}/image`, formData);
|
||||
document.getElementById('noteImage').src = result.url;
|
||||
document.getElementById('noteImageContainer').style.display = 'block';
|
||||
await loadNotes();
|
||||
} catch (error) {
|
||||
console.error('Erreur upload image:', error);
|
||||
alert('Erreur lors de l\'upload de l\'image');
|
||||
}
|
||||
}
|
||||
|
||||
async function removeNoteImage() {
|
||||
if (!currentNoteId) return;
|
||||
if (!confirm('Supprimer l\'image ?')) return;
|
||||
|
||||
try {
|
||||
await api.delete(`/api/notes/${currentNoteId}/image`);
|
||||
document.getElementById('noteImageContainer').style.display = 'none';
|
||||
await loadNotes();
|
||||
} catch (error) {
|
||||
console.error('Erreur suppression image:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== TODOS GLOBAUX ====================
|
||||
async function loadTodos() {
|
||||
try {
|
||||
state.todos = await api.get('/api/todos');
|
||||
renderTodos();
|
||||
} catch (error) {
|
||||
console.error('Erreur chargement todos:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function renderTodos() {
|
||||
const todoList = document.getElementById('todoList');
|
||||
if (!todoList) return;
|
||||
|
||||
todoList.innerHTML = '';
|
||||
|
||||
const filtered = state.todos.filter(todo => {
|
||||
if (state.filter === 'active') return !todo.completed;
|
||||
if (state.filter === 'completed') return todo.completed;
|
||||
return true;
|
||||
});
|
||||
|
||||
filtered.forEach(todo => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'todo-item' + (todo.completed ? ' completed' : '');
|
||||
item.innerHTML = `
|
||||
<input type="checkbox" class="todo-checkbox" ${todo.completed ? 'checked' : ''}
|
||||
onchange="toggleTodo(${todo.id}, this.checked)">
|
||||
<span class="todo-text">${escapeHtml(todo.text)}</span>
|
||||
<button class="todo-delete" onclick="deleteTodo(${todo.id})">✕</button>
|
||||
`;
|
||||
todoList.appendChild(item);
|
||||
});
|
||||
|
||||
updateTodoCounter();
|
||||
}
|
||||
|
||||
async function addTodo() {
|
||||
const input = document.getElementById('todoInput');
|
||||
const text = input.value.trim();
|
||||
|
||||
if (!text) return;
|
||||
|
||||
try {
|
||||
await api.post('/api/todos', { text });
|
||||
input.value = '';
|
||||
await loadTodos();
|
||||
} catch (error) {
|
||||
console.error('Erreur ajout todo:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleTodo(id, completed) {
|
||||
try {
|
||||
await api.put(`/api/todos/${id}`, { completed });
|
||||
await loadTodos();
|
||||
} catch (error) {
|
||||
console.error('Erreur toggle todo:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteTodo(id) {
|
||||
try {
|
||||
await api.delete(`/api/todos/${id}`);
|
||||
await loadTodos();
|
||||
} catch (error) {
|
||||
console.error('Erreur suppression todo:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function setTodoFilter(filter) {
|
||||
state.filter = filter;
|
||||
document.querySelectorAll('.filter-btn').forEach(btn => {
|
||||
btn.classList.toggle('active', btn.dataset.filter === filter);
|
||||
});
|
||||
renderTodos();
|
||||
}
|
||||
|
||||
function updateTodoCounter() {
|
||||
const counter = document.getElementById('todoCounter');
|
||||
if (!counter) return;
|
||||
|
||||
const remaining = state.todos.filter(t => !t.completed).length;
|
||||
counter.textContent = `${remaining} tâche${remaining > 1 ? 's' : ''} restante${remaining > 1 ? 's' : ''}`;
|
||||
}
|
||||
|
||||
// ==================== SEARCH ====================
|
||||
function setupSearch() {
|
||||
const searchInput = document.getElementById('searchInput');
|
||||
const searchClear = document.getElementById('searchClear');
|
||||
|
||||
if (!searchInput) return;
|
||||
|
||||
const performSearch = utils.debounce(() => {
|
||||
state.searchQuery = searchInput.value.trim();
|
||||
renderNotes();
|
||||
|
||||
if (state.searchQuery) {
|
||||
searchClear.style.display = 'block';
|
||||
} else {
|
||||
searchClear.style.display = 'none';
|
||||
}
|
||||
}, 300);
|
||||
|
||||
searchInput.addEventListener('input', performSearch);
|
||||
|
||||
if (searchClear) {
|
||||
searchClear.addEventListener('click', () => {
|
||||
searchInput.value = '';
|
||||
state.searchQuery = '';
|
||||
searchClear.style.display = 'none';
|
||||
renderNotes();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== INIT ====================
|
||||
async function init() {
|
||||
// Vérifier l'authentification
|
||||
await initAuth();
|
||||
|
||||
// Charger les données
|
||||
await Promise.all([loadNotes(), loadTodos()]);
|
||||
|
||||
// Setup search
|
||||
setupSearch();
|
||||
|
||||
// Event listeners
|
||||
const newNoteBtn = document.getElementById('newNoteBtn');
|
||||
if (newNoteBtn) {
|
||||
newNoteBtn.addEventListener('click', () => openNoteModal());
|
||||
}
|
||||
|
||||
const closeModal = document.getElementById('closeModal');
|
||||
if (closeModal) {
|
||||
closeModal.addEventListener('click', closeNoteModal);
|
||||
}
|
||||
|
||||
const deleteNote = document.getElementById('deleteNote');
|
||||
if (deleteNote) {
|
||||
deleteNote.addEventListener('click', deleteCurrentNote);
|
||||
}
|
||||
|
||||
const addImageBtn = document.getElementById('addImageBtn');
|
||||
if (addImageBtn) {
|
||||
addImageBtn.addEventListener('click', addNoteImage);
|
||||
}
|
||||
|
||||
const imageInput = document.getElementById('imageInput');
|
||||
if (imageInput) {
|
||||
imageInput.addEventListener('change', handleImageUpload);
|
||||
}
|
||||
|
||||
const removeImage = document.getElementById('removeImage');
|
||||
if (removeImage) {
|
||||
removeImage.addEventListener('click', removeNoteImage);
|
||||
}
|
||||
|
||||
const addNoteTodoBtn = document.getElementById('addNoteTodo');
|
||||
if (addNoteTodoBtn) {
|
||||
addNoteTodoBtn.addEventListener('click', addNoteTodo);
|
||||
}
|
||||
|
||||
const addTodoBtn = document.getElementById('addTodoBtn');
|
||||
if (addTodoBtn) {
|
||||
addTodoBtn.addEventListener('click', addTodo);
|
||||
}
|
||||
|
||||
const todoInput = document.getElementById('todoInput');
|
||||
if (todoInput) {
|
||||
todoInput.addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') addTodo();
|
||||
});
|
||||
}
|
||||
|
||||
// Todo filters
|
||||
document.querySelectorAll('.filter-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => setTodoFilter(btn.dataset.filter));
|
||||
});
|
||||
|
||||
// Close modal on backdrop click
|
||||
const noteModal = document.getElementById('noteModal');
|
||||
if (noteModal) {
|
||||
noteModal.addEventListener('click', (e) => {
|
||||
if (e.target === noteModal) closeNoteModal();
|
||||
});
|
||||
}
|
||||
|
||||
// Close modal on Escape
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
closeNoteModal();
|
||||
document.getElementById('adminModal').style.display = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Démarrer l'application
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Connexion - NoteFlow</title>
|
||||
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
color: #1F2937;
|
||||
}
|
||||
|
||||
.login-container {
|
||||
background: white;
|
||||
padding: 48px;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
animation: slideIn 0.4s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.login-logo {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.login-logo h1 {
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.login-logo p {
|
||||
color: #6B7280;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
border: 2px solid #E5E7EB;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.form-group input:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background: #FEE2E2;
|
||||
color: #991B1B;
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.error-message.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.login-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.login-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.login-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.default-credentials {
|
||||
margin-top: 24px;
|
||||
padding: 16px;
|
||||
background: #F3F4F6;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
color: #6B7280;
|
||||
}
|
||||
|
||||
.default-credentials strong {
|
||||
color: #374151;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-container">
|
||||
<div class="login-logo">
|
||||
<h1>📝 NoteFlow</h1>
|
||||
<p>Gérez vos notes et tâches efficacement</p>
|
||||
</div>
|
||||
|
||||
<div id="errorMessage" class="error-message"></div>
|
||||
|
||||
<form id="loginForm">
|
||||
<div class="form-group">
|
||||
<label for="username">Nom d'utilisateur</label>
|
||||
<input type="text" id="username" name="username" required autocomplete="username">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">Mot de passe</label>
|
||||
<input type="password" id="password" name="password" required autocomplete="current-password">
|
||||
</div>
|
||||
|
||||
<button type="submit" class="login-btn" id="loginBtn">Se connecter</button>
|
||||
</form>
|
||||
|
||||
<div class="default-credentials">
|
||||
<strong>Identifiants par défaut:</strong><br>
|
||||
Username: admin<br>
|
||||
Password: admin
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Rediriger si déjà connecté
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
window.location.href = '/';
|
||||
}
|
||||
|
||||
const loginForm = document.getElementById('loginForm');
|
||||
const errorMessage = document.getElementById('errorMessage');
|
||||
const loginBtn = document.getElementById('loginBtn');
|
||||
|
||||
loginForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const username = document.getElementById('username').value;
|
||||
const password = document.getElementById('password').value;
|
||||
|
||||
errorMessage.classList.remove('show');
|
||||
loginBtn.disabled = true;
|
||||
loginBtn.textContent = 'Connexion...';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
localStorage.setItem('token', data.token);
|
||||
localStorage.setItem('user', JSON.stringify(data.user));
|
||||
window.location.href = '/';
|
||||
} else {
|
||||
errorMessage.textContent = data.error || 'Identifiants invalides';
|
||||
errorMessage.classList.add('show');
|
||||
}
|
||||
} catch (error) {
|
||||
errorMessage.textContent = 'Erreur de connexion au serveur';
|
||||
errorMessage.classList.add('show');
|
||||
} finally {
|
||||
loginBtn.disabled = false;
|
||||
loginBtn.textContent = 'Se connecter';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in new issue
Block a user