fix: unify theme system - fix theme switching persistence
- Unified localStorage key to 'theme-preference' across all components
- Fixed header.tsx using wrong localStorage key ('theme' instead of 'theme-preference')
- Added localStorage hybrid persistence for instant theme changes
- Removed router.refresh() which was causing stale data revert
- Replaced Blue theme with Sepia
- Consolidated auth() calls to prevent race conditions
- Updated UserSettingsData types to include all themes
This commit is contained in:
@@ -0,0 +1,416 @@
|
||||
---
|
||||
title: 'Fix Muuri Masonry Grid - Drag & Drop et Layout Responsive'
|
||||
slug: 'fix-muuri-masonry-grid'
|
||||
created: '2026-01-18'
|
||||
status: 'ready-for-dev'
|
||||
stepsCompleted: [1, 2, 3, 4]
|
||||
tech_stack: ['muuri@0.9.5', 'react@19.2.3', 'typescript@5.x', 'next.js@16.1.1', 'web-animations-js']
|
||||
files_to_modify:
|
||||
- 'components/masonry-grid.tsx'
|
||||
- 'components/note-card.tsx'
|
||||
- 'components/masonry-grid.css'
|
||||
- 'config/masonry-layout.ts'
|
||||
- 'tests/drag-drop.spec.ts'
|
||||
code_patterns:
|
||||
- 'Dynamic Muuri import (SSR-safe)'
|
||||
- 'useResizeObserver hook with RAF debounce'
|
||||
- 'NotebookDragContext for cross-component state'
|
||||
- 'dragHandle: .muuri-drag-handle (mobile only)'
|
||||
- 'NoteSize type: small | medium | large'
|
||||
test_patterns:
|
||||
- 'Playwright E2E with [data-draggable="true"] selectors'
|
||||
- 'API cleanup in beforeAll/afterEach'
|
||||
- 'dragTo() for reliable drag operations'
|
||||
---
|
||||
|
||||
# Tech-Spec: Fix Muuri Masonry Grid - Drag & Drop et Layout Responsive
|
||||
|
||||
**Created:** 2026-01-18
|
||||
**Status:** 🔍 Review
|
||||
|
||||
## Overview
|
||||
|
||||
### Problem Statement
|
||||
|
||||
Le système de grille masonry avec Muuri présente 4 problèmes critiques:
|
||||
|
||||
1. **❌ Drag & Drop cassé** - Les tests Playwright cherchent `data-draggable="true"` mais l'attribut est sur `NoteCard` (ligne 273), pas sur le `MasonryItem` wrapper que Muuri manipule.
|
||||
|
||||
2. **❌ Tailles de notes non gérées** - Les notes ont `data-size` mais Muuri ne recalcule pas le layout après le rendu du contenu. La fonction `getItemDimensions` est définie mais jamais réutilisée lors des syncs.
|
||||
|
||||
3. **❌ Layout non responsive** - Les colonnes sont calculées via `calculateColumns()` mais les largeurs ne sont appliquées qu'une seule fois. Le `useEffect` de sync (lignes 295-322) ne gère pas l'ajout/suppression d'items.
|
||||
|
||||
4. **❌ Synchronisation items cassée** - Quand React ajoute/supprime des notes, Muuri n'est pas notifié. Les nouveaux items ne sont pas ajoutés à la grille Muuri.
|
||||
|
||||
### Solution
|
||||
|
||||
Refactoriser l'intégration Muuri en 5 tâches:
|
||||
|
||||
1. Propager `data-draggable="true"` au `MasonryItem` wrapper
|
||||
2. Centraliser le calcul des dimensions dans une fonction réutilisable
|
||||
3. Utiliser `ResizeObserver` sur le conteneur principal
|
||||
4. Synchroniser les items DOM avec Muuri après chaque rendu React
|
||||
5. Vérifier les tests Playwright
|
||||
|
||||
### Scope
|
||||
|
||||
**In Scope:**
|
||||
- ✅ Correction du drag & drop Muuri
|
||||
- ✅ Layout responsive avec colonnes dynamiques (1→5 selon largeur)
|
||||
- ✅ Gestion correcte des tailles (small/medium/large)
|
||||
- ✅ Compatibilité tests Playwright existants
|
||||
|
||||
**Out of Scope:**
|
||||
- ❌ Nouvelles tailles de notes
|
||||
- ❌ Migration vers autre librairie
|
||||
- ❌ Modification persistance ordre
|
||||
|
||||
---
|
||||
|
||||
## Context for Development
|
||||
|
||||
### Codebase Patterns
|
||||
|
||||
**Import dynamique Muuri (SSR-safe):**
|
||||
```typescript
|
||||
const MuuriClass = (await import('muuri')).default;
|
||||
pinnedMuuri.current = new MuuriClass(pinnedGridRef.current, layoutOptions);
|
||||
```
|
||||
|
||||
**Hook useResizeObserver existant:**
|
||||
```typescript
|
||||
// hooks/use-resize-observer.ts
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
if (frameId.current) cancelAnimationFrame(frameId.current);
|
||||
frameId.current = requestAnimationFrame(() => {
|
||||
for (const entry of entries) callback(entry);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**NotebookDragContext (état cross-component):**
|
||||
```typescript
|
||||
const { startDrag, endDrag, draggedNoteId } = useNotebookDrag();
|
||||
```
|
||||
|
||||
**Drag handle mobile:**
|
||||
```typescript
|
||||
dragHandle: isMobile ? '.muuri-drag-handle' : undefined,
|
||||
```
|
||||
|
||||
### Files to Reference
|
||||
|
||||
| File | Purpose | Lines clés |
|
||||
| ---- | ------- | ---------- |
|
||||
| [masonry-grid.tsx](file:///d:/dev_new_pc/Keep/keep-notes/components/masonry-grid.tsx) | Composant grille Muuri | 116-292 (init), 295-322 (sync) |
|
||||
| [note-card.tsx](file:///d:/dev_new_pc/Keep/keep-notes/components/note-card.tsx) | Carte note avec data-draggable | 271-301 (Card props) |
|
||||
| [masonry-grid.css](file:///d:/dev_new_pc/Keep/keep-notes/components/masonry-grid.css) | Styles tailles et drag | 54-67, 70-97 |
|
||||
| [masonry-layout.ts](file:///d:/dev_new_pc/Keep/keep-notes/config/masonry-layout.ts) | Config breakpoints | 81-90 (calculateColumns) |
|
||||
| [drag-drop.spec.ts](file:///d:/dev_new_pc/Keep/keep-notes/tests/drag-drop.spec.ts) | Tests E2E | 45, 75-78 (data-draggable) |
|
||||
|
||||
### Technical Decisions
|
||||
|
||||
1. **Garder Muuri** - Fonctionne pour masonry, on corrige l'intégration
|
||||
2. **Réutiliser useResizeObserver** - Hook existant avec RAF debounce
|
||||
3. **Hauteur auto** - Comme Google Keep, contenu détermine hauteur
|
||||
4. **Largeur fixe** - Toutes notes même largeur par colonne
|
||||
|
||||
---
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Tasks
|
||||
|
||||
#### Task 1: Ajouter `data-draggable` au MasonryItem wrapper
|
||||
|
||||
- [ ] **File:** `components/masonry-grid.tsx`
|
||||
- [ ] **Action:** Ajouter `data-draggable="true"` au div wrapper `.masonry-item`
|
||||
- [ ] **Lignes:** 32-37
|
||||
|
||||
```typescript
|
||||
// AVANT (ligne 32-37)
|
||||
<div
|
||||
className="masonry-item absolute py-1"
|
||||
data-id={note.id}
|
||||
data-size={note.size}
|
||||
ref={resizeRef as any}
|
||||
style={{ width: 'auto', height: 'auto' }}
|
||||
>
|
||||
|
||||
// APRÈS
|
||||
<div
|
||||
className="masonry-item absolute py-1"
|
||||
data-id={note.id}
|
||||
data-size={note.size}
|
||||
data-draggable="true"
|
||||
ref={resizeRef as any}
|
||||
style={{ width: 'auto', height: 'auto' }}
|
||||
>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### Task 2: Créer fonction `applyItemDimensions` réutilisable
|
||||
|
||||
- [ ] **File:** `components/masonry-grid.tsx`
|
||||
- [ ] **Action:** Extraire la logique de calcul des dimensions dans une fonction callback
|
||||
- [ ] **Position:** Après la ligne 109 (refreshLayout)
|
||||
|
||||
```typescript
|
||||
// Nouvelle fonction à ajouter après refreshLayout
|
||||
const applyItemDimensions = useCallback((grid: any, containerWidth: number) => {
|
||||
if (!grid) return;
|
||||
|
||||
const columns = calculateColumns(containerWidth);
|
||||
const itemWidth = calculateItemWidth(containerWidth, columns);
|
||||
|
||||
const items = grid.getItems();
|
||||
items.forEach((item: any) => {
|
||||
const el = item.getElement();
|
||||
if (el) {
|
||||
el.style.width = `${itemWidth}px`;
|
||||
// Height auto - determined by content (Google Keep style)
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### Task 3: Améliorer la gestion du resize avec ResizeObserver sur conteneur
|
||||
|
||||
- [ ] **File:** `components/masonry-grid.tsx`
|
||||
- [ ] **Action:** Remplacer `window.addEventListener('resize')` par ResizeObserver sur `.masonry-container`
|
||||
- [ ] **Lignes:** 325-378 (useEffect resize)
|
||||
|
||||
```typescript
|
||||
// REMPLACER le useEffect de resize (lignes 325-378)
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || (!pinnedMuuri.current && !othersMuuri.current)) return;
|
||||
|
||||
let resizeTimeout: NodeJS.Timeout;
|
||||
|
||||
const handleResize = (entries: ResizeObserverEntry[]) => {
|
||||
clearTimeout(resizeTimeout);
|
||||
resizeTimeout = setTimeout(() => {
|
||||
const containerWidth = entries[0]?.contentRect.width || window.innerWidth - 32;
|
||||
const columns = calculateColumns(containerWidth);
|
||||
const itemWidth = calculateItemWidth(containerWidth, columns);
|
||||
|
||||
console.log(`[Masonry Resize] Width: ${containerWidth}px, Columns: ${columns}`);
|
||||
|
||||
// Apply dimensions to both grids
|
||||
applyItemDimensions(pinnedMuuri.current, containerWidth);
|
||||
applyItemDimensions(othersMuuri.current, containerWidth);
|
||||
|
||||
// Refresh layouts
|
||||
requestAnimationFrame(() => {
|
||||
pinnedMuuri.current?.refreshItems().layout();
|
||||
othersMuuri.current?.refreshItems().layout();
|
||||
});
|
||||
}, 150);
|
||||
};
|
||||
|
||||
const observer = new ResizeObserver(handleResize);
|
||||
observer.observe(containerRef.current);
|
||||
|
||||
// Initial layout
|
||||
handleResize([{ contentRect: containerRef.current.getBoundingClientRect() } as ResizeObserverEntry]);
|
||||
|
||||
return () => {
|
||||
clearTimeout(resizeTimeout);
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [applyItemDimensions]);
|
||||
```
|
||||
|
||||
- [ ] **Action:** Ajouter `ref={containerRef}` au div `.masonry-container` (ligne 381)
|
||||
|
||||
```typescript
|
||||
// AVANT
|
||||
<div className="masonry-container">
|
||||
|
||||
// APRÈS
|
||||
<div ref={containerRef} className="masonry-container">
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### Task 4: Synchroniser items DOM ↔ Muuri après rendu React
|
||||
|
||||
- [ ] **File:** `components/masonry-grid.tsx`
|
||||
- [ ] **Action:** Améliorer le useEffect de sync pour gérer ajout/suppression d'items
|
||||
- [ ] **Lignes:** 295-322
|
||||
|
||||
```typescript
|
||||
// REMPLACER le useEffect de sync (lignes 295-322)
|
||||
useEffect(() => {
|
||||
const syncGridItems = (grid: any, gridRef: React.RefObject<HTMLDivElement>, notesArray: Note[]) => {
|
||||
if (!grid || !gridRef.current) return;
|
||||
|
||||
const containerWidth = containerRef.current?.getBoundingClientRect().width || window.innerWidth - 32;
|
||||
const columns = calculateColumns(containerWidth);
|
||||
const itemWidth = calculateItemWidth(containerWidth, columns);
|
||||
|
||||
// Get current DOM elements and Muuri items
|
||||
const domElements = Array.from(gridRef.current.children) as HTMLElement[];
|
||||
const muuriItems = grid.getItems();
|
||||
const muuriElements = muuriItems.map((item: any) => item.getElement());
|
||||
|
||||
// Find new elements to add
|
||||
const newElements = domElements.filter(el => !muuriElements.includes(el));
|
||||
|
||||
// Find removed elements
|
||||
const removedItems = muuriItems.filter((item: any) =>
|
||||
!domElements.includes(item.getElement())
|
||||
);
|
||||
|
||||
// Remove old items
|
||||
if (removedItems.length > 0) {
|
||||
grid.remove(removedItems, { layout: false });
|
||||
}
|
||||
|
||||
// Add new items with correct width
|
||||
if (newElements.length > 0) {
|
||||
newElements.forEach(el => {
|
||||
el.style.width = `${itemWidth}px`;
|
||||
});
|
||||
grid.add(newElements, { layout: false });
|
||||
}
|
||||
|
||||
// Update all item widths
|
||||
domElements.forEach(el => {
|
||||
el.style.width = `${itemWidth}px`;
|
||||
});
|
||||
|
||||
// Refresh and layout
|
||||
grid.refreshItems().layout();
|
||||
};
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
syncGridItems(pinnedMuuri.current, pinnedGridRef, pinnedNotes);
|
||||
syncGridItems(othersMuuri.current, othersGridRef, othersNotes);
|
||||
});
|
||||
}, [pinnedNotes, othersNotes]);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### Task 5: Vérifier les tests Playwright
|
||||
|
||||
- [ ] **File:** `tests/drag-drop.spec.ts`
|
||||
- [ ] **Action:** Exécuter les tests et vérifier que les sélecteurs `[data-draggable="true"]` matchent le wrapper
|
||||
- [ ] **Commande:** `npx playwright test drag-drop.spec.ts`
|
||||
|
||||
**Points de vérification:**
|
||||
- Ligne 45: `page.locator('[data-draggable="true"]')` doit trouver les `.masonry-item` wrappers
|
||||
- Ligne 149: `firstNote.dragTo(secondNote)` doit fonctionner avec Muuri
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
### AC1: Drag & Drop fonctionnel
|
||||
|
||||
- [ ] **Given** une grille de notes affichée
|
||||
- [ ] **When** je drag une note vers une autre position
|
||||
- [ ] **Then** la note se déplace visuellement avec placeholder
|
||||
- [ ] **And** l'ordre est persisté après le drop
|
||||
|
||||
### AC2: Layout responsive
|
||||
|
||||
- [ ] **Given** une grille de notes avec différentes tailles
|
||||
- [ ] **When** je redimensionne la fenêtre du navigateur
|
||||
- [ ] **Then** le nombre de colonnes s'adapte:
|
||||
- < 480px: 1 colonne
|
||||
- 480-768px: 2 colonnes
|
||||
- 768-1024px: 2 colonnes
|
||||
- 1024-1280px: 3 colonnes
|
||||
- 1280-1600px: 4 colonnes
|
||||
- > 1600px: 5 colonnes
|
||||
|
||||
### AC3: Tailles de notes respectées
|
||||
|
||||
- [ ] **Given** une note avec `data-size="large"`
|
||||
- [ ] **When** la note est affichée dans la grille
|
||||
- [ ] **Then** elle a une `min-height` de 300px
|
||||
- [ ] **And** sa hauteur finale est déterminée par son contenu
|
||||
|
||||
### AC4: Synchronisation React-Muuri
|
||||
|
||||
- [ ] **Given** une grille avec des notes
|
||||
- [ ] **When** j'ajoute une nouvelle note via l'input
|
||||
- [ ] **Then** la note apparaît dans la grille avec les bonnes dimensions
|
||||
- [ ] **And** elle est draggable immédiatement
|
||||
|
||||
### AC5: Tests Playwright passants
|
||||
|
||||
- [ ] **Given** les tests Playwright existants
|
||||
- [ ] **When** j'exécute `npx playwright test drag-drop.spec.ts`
|
||||
- [ ] **Then** tous les tests passent avec les sélecteurs `[data-draggable="true"]`
|
||||
|
||||
---
|
||||
|
||||
## Additional Context
|
||||
|
||||
### Dependencies
|
||||
|
||||
| Dépendance | Version | Usage |
|
||||
|------------|---------|-------|
|
||||
| muuri | ^0.9.5 | Grille masonry avec drag & drop |
|
||||
| web-animations-js | (bundled) | Polyfill animations |
|
||||
| ResizeObserver | Native | Détection resize conteneur |
|
||||
|
||||
### Testing Strategy
|
||||
|
||||
**Tests automatisés:**
|
||||
```bash
|
||||
# Exécuter tests drag-drop
|
||||
npx playwright test drag-drop.spec.ts
|
||||
|
||||
# Exécuter tests responsive (à ajouter)
|
||||
npx playwright test --grep "responsive"
|
||||
```
|
||||
|
||||
**Tests manuels:**
|
||||
1. Ouvrir l'app sur différentes tailles d'écran
|
||||
2. Vérifier le nombre de colonnes selon breakpoints
|
||||
3. Drag une note et vérifier le placeholder
|
||||
4. Ajouter une note et vérifier qu'elle est draggable
|
||||
5. Redimensionner la fenêtre et vérifier le re-layout
|
||||
|
||||
### Notes & Risques
|
||||
|
||||
> [!WARNING]
|
||||
> **Risque: Synchronisation timing**
|
||||
> Le `requestAnimationFrame` dans `syncGridItems` doit s'exécuter APRÈS que React ait rendu les nouveaux éléments DOM. Si des problèmes de timing apparaissent, utiliser `setTimeout(..., 0)` ou `MutationObserver`.
|
||||
|
||||
> [!NOTE]
|
||||
> **Comportement Google Keep**
|
||||
> Google Keep utilise des hauteurs automatiques basées sur le contenu. On ne fixe pas de hauteur, seulement la largeur. Muuri gère le positionnement vertical automatiquement.
|
||||
|
||||
> [!TIP]
|
||||
> **Debug Muuri**
|
||||
> Ajouter `console.log` dans `handleDragEnd` pour vérifier que l'ordre est bien capturé après un drag.
|
||||
|
||||
---
|
||||
|
||||
## Ordre d'exécution recommandé
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
T1[Task 1: data-draggable] --> T4[Task 4: Sync React-Muuri]
|
||||
T2[Task 2: applyItemDimensions] --> T3[Task 3: ResizeObserver]
|
||||
T3 --> T4
|
||||
T4 --> T5[Task 5: Tests Playwright]
|
||||
```
|
||||
|
||||
1. **Task 1** (5 min) - Modification simple, débloque les tests
|
||||
2. **Task 2** (10 min) - Refactoring fonction, prépare Task 3
|
||||
3. **Task 3** (15 min) - ResizeObserver, dépend de Task 2
|
||||
4. **Task 4** (20 min) - Sync React-Muuri, le plus critique
|
||||
5. **Task 5** (5 min) - Validation finale
|
||||
|
||||
**Temps estimé total:** ~55 minutes
|
||||
Reference in New Issue
Block a user