Cas d'utilisation

/

Développement rapide

Accélérez le développement des MVP

modèle

Claude 3.7 Sonnet

Créez une application de notes MVP SaaS : connexion sécurisée, notes CRUD et recherche de balises à l'aide de Django + React.

Essayez Query
Claude 3.7 Sonnet
/
codeur

Création d'une application MVP SaaS Notes avec Django + React

Voici un plan pour créer un produit minimal viable (MVP) pour une application de notes avec une connexion sécurisée, des opérations CRUD et une fonctionnalité de recherche de balises en utilisant Django pour le backend et React pour le frontend.

Présentation de l'architecture

1Notes App
2├── Backend (Django)
3│   ├── Authentication
4│   ├── Notes API
5│   └── Search & Tags
6└── Frontend (React)
7    ├── Auth Components
8    ├── Note Management
9    └── Search Interface

Implémentation du backend (Django)

1. Configuration du projet

1# Install Django and required packages
2pip install django djangorestframework django-cors-headers djoser
3
4# Create Django project
5django-admin startproject notes_backend
6cd notes_backend
7
8# Create apps
9python manage.py startapp users
10python manage.py startapp notes

2. Configuration des paramètres (settings.py)

1INSTALLED_APPS = [
2    # Django apps
3    'django.contrib.admin',
4    'django.contrib.auth',
5    'django.contrib.contenttypes',
6    'django.contrib.sessions',
7    'django.contrib.messages',
8    'django.contrib.staticfiles',
9    
10    # Third-party apps
11    'rest_framework',
12    'corsheaders',
13    'djoser',
14    
15    # Local apps
16    'users',
17    'notes',
18]
19
20MIDDLEWARE = [
21    'corsheaders.middleware.CorsMiddleware',
22    # Other middleware...
23]
24
25# CORS settings
26CORS_ALLOWED_ORIGINS = [
27    "http://localhost:3000",  # React frontend
28]
29
30# REST Framework settings
31REST_FRAMEWORK = {
32    'DEFAULT_AUTHENTICATION_CLASSES': (
33        'rest_framework_simplejwt.authentication.JWTAuthentication',
34    ),
35    'DEFAULT_PERMISSION_CLASSES': [
36        'rest_framework.permissions.IsAuthenticated',
37    ]
38}
39
40# JWT settings
41SIMPLE_JWT = {
42    'AUTH_HEADER_TYPES': ('JWT',),
43    'ACCESS_TOKEN_LIFETIME': timedelta(hours=1),
44    'REFRESH_TOKEN_LIFETIME': timedelta(days=7),
45}

3. Modèles (notes/models.py)

1from django.db import models
2from django.contrib.auth import get_user_model
3
4User = get_user_model()
5
6class Tag(models.Model):
7    name = models.CharField(max_length=50)
8    user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='tags')
9    
10    def __str__(self):
11        return self.name
12    
13    class Meta:
14        unique_together = ['name', 'user']
15
16class Note(models.Model):
17    title = models.CharField(max_length=200)
18    content = models.TextField()
19    created_at = models.DateTimeField(auto_now_add=True)
20    updated_at = models.DateTimeField(auto_now=True)
21    user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='notes')
22    tags = models.ManyToManyField(Tag, related_name='notes', blank=True)
23    
24    def __str__(self):
25        return self.title
26    
27    class Meta:
28        ordering = ['-updated_at']

4. Sérialiseurs (notes/serializers.py)

1from rest_framework import serializers
2from .models import Note, Tag
3
4class TagSerializer(serializers.ModelSerializer):
5    class Meta:
6        model = Tag
7        fields = ['id', 'name']
8
9class NoteSerializer(serializers.ModelSerializer):
10    tags = TagSerializer(many=True, read_only=True)
11    tag_ids = serializers.PrimaryKeyRelatedField(
12        many=True, 
13        write_only=True, 
14        queryset=Tag.objects.all(),
15        source='tags',
16        required=False
17    )
18    
19    class Meta:
20        model = Note
21        fields = ['id', 'title', 'content', 'created_at', 'updated_at', 'tags', 'tag_ids']
22        read_only_fields = ['created_at', 'updated_at']

5. Affichages (notes/views.py)

1from rest_framework import viewsets, filters
2from rest_framework.permissions import IsAuthenticated
3from django_filters.rest_framework import DjangoFilterBackend
4from .models import Note, Tag
5from .serializers import NoteSerializer, TagSerializer
6
7class TagViewSet(viewsets.ModelViewSet):
8    serializer_class = TagSerializer
9    permission_classes = [IsAuthenticated]
10    
11    def get_queryset(self):
12        return Tag.objects.filter(user=self.request.user)
13    
14    def perform_create(self, serializer):
15        serializer.save(user=self.request.user)
16
17class NoteViewSet(viewsets.ModelViewSet):
18    serializer_class = NoteSerializer
19    permission_classes = [IsAuthenticated]
20    filter_backends = [DjangoFilterBackend, filters.SearchFilter]
21    filterset_fields = ['tags__name']
22    search_fields = ['title', 'content', 'tags__name']
23    
24    def get_queryset(self):
25        return Note.objects.filter(user=self.request.user)
26    
27    def perform_create(self, serializer):
28        serializer.save(user=self.request.user)

6. URL (notes_backend/urls.py)

1from django.contrib import admin
2from django.urls import path, include
3from rest_framework.routers import DefaultRouter
4from notes.views import NoteViewSet, TagViewSet
5
6router = DefaultRouter()
7router.register(r'notes', NoteViewSet, basename='note')
8router.register(r'tags', TagViewSet, basename='tag')
9
10urlpatterns = [
11    path('admin/', admin.site.urls),
12    path('api/', include(router.urls)),
13    path('api/auth/', include('djoser.urls')),
14    path('api/auth/', include('djoser.urls.jwt')),
15]

7. Exécuter des migrations

1python manage.py makemigrations
2python manage.py migrate
3python manage.py runserver

Implémentation du frontend (React)

1. Configuration du projet

1npx create-react-app notes-frontend
2cd notes-frontend
3npm install axios react-router-dom formik yup @mui/material @emotion/react @emotion/styled

2. Service d'API (src/services/api.js)

1import axios from 'axios';
2
3const API_URL = 'http://localhost:8000/api/';
4
5// Create axios instance
6const api = axios.create({
7  baseURL: API_URL,
8  headers: {
9    'Content-Type': 'application/json',
10  },
11});
12
13// Add token to requests
14api.interceptors.request.use(
15  (config) => {
16    const token = localStorage.getItem('token');
17    if (token) {
18      config.headers['Authorization'] = `JWT ${token}`;
19    }
20    return config;
21  },
22  (error) => Promise.reject(error)
23);
24
25// Auth services
26export const login = (email, password) => {
27  return api.post('auth/jwt/create/', { email, password });
28};
29
30export const register = (email, password, name) => {
31  return api.post('auth/users/', { email, password, name });
32};
33
34// Note services
35export const getNotes = (search = '') => {
36  return api.get(`notes/${search ? `?search=${search}` : ''}`);
37};
38
39export const getNote = (id) => {
40  return api.get(`notes/${id}/`);
41};
42
43export const createNote = (note) => {
44  return api.post('notes/', note);
45};
46
47export const updateNote = (id, note) => {
48  return api.put(`notes/${id}/`, note);
49};
50
51export const deleteNote = (id) => {
52  return api.delete(`notes/${id}/`);
53};
54
55// Tag services
56export const getTags = () => {
57  return api.get('tags/');
58};
59
60export const createTag = (tag) => {
61  return api.post('tags/', tag);
62};
63
64export default api;

3. Contexte d'authentification (src/contexts/AuthContext.js)

1import React, { createContext, useState, useEffect, useContext } from 'react';
2import { login as apiLogin, register as apiRegister } from '../services/api';
3
4const AuthContext = createContext();
5
6export const AuthProvider = ({ children }) => {
7  const [user, setUser] = useState(null);
8  const [loading, setLoading] = useState(true);
9
10  useEffect(() => {
11    // Check if user is logged in
12    const token = localStorage.getItem('token');
13    if (token) {
14      setUser({ token });
15    }
16    setLoading(false);
17  }, []);
18
19  const login = async (email, password) => {
20    try {
21      const response = await apiLogin(email, password);
22      const { access } = response.data;
23      localStorage.setItem('token', access);
24      setUser({ token: access });
25      return true;
26    } catch (error) {
27      console.error('Login error:', error);
28      return false;
29    }
30  };
31
32  const register = async (email, password, name) => {
33    try {
34      await apiRegister(email, password, name);
35      return await login(email, password);
36    } catch (error) {
37      console.error('Registration error:', error);
38      return false;
39    }
40  };
41
42  const logout = () => {
43    localStorage.removeItem('token');
44    setUser(null);
45  };
46
47  return (
48    <AuthContext.Provider value={{ user, login, register, logout, loading }}>
49      {children}
50    </AuthContext.Provider>
51  );
52};
53
54export const useAuth = () => useContext(AuthContext);

4. Structure des composants

Composant de connexion (src/components/Login.js)

1import React from 'react';
2import { useFormik } from 'formik';
3import * as Yup from 'yup';
4import { TextField, Button, Typography, Box, Container } from '@mui/material';
5import { useAuth } from '../contexts/AuthContext';
6import { useNavigate } from 'react-router-dom';
7
8const Login = () => {
9  const { login } = useAuth();
10  const navigate = useNavigate();
11
12  const formik = useFormik({
13    initialValues: {
14      email: '',
15      password: '',
16    },
17    validationSchema: Yup.object({
18      email: Yup.string().email('Invalid email address').required('Required'),
19      password: Yup.string().required('Required'),
20    }),
21    onSubmit: async (values) => {
22      const success = await login(values.email, values.password);
23      if (success) {
24        navigate('/notes');
25      }
26    },
27  });
28
29  return (
30    <Container maxWidth="sm">
31      <Box sx={{ mt: 8, display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
32        <Typography component="h1" variant="h5">
33          Sign in
34        </Typography>
35        <Box component="form" onSubmit={formik.handleSubmit} sx={{ mt: 1 }}>
36          <TextField
37            margin="normal"
38            fullWidth
39            id="email"
40            label="Email Address"
41            name="email"
42            autoComplete="email"
43            value={formik.values.email}
44            onChange={formik.handleChange}
45            error={formik.touched.email && Boolean(formik.errors.email)}
46            helperText={formik.touched.email && formik.errors.email}
47          />
48          <TextField
49            margin="normal"
50            fullWidth
51            name="password"
52            label="Password"
53            type="password"
54            id="password"
55            autoComplete="current-password"
56            value={formik.values.password}
57            onChange={formik.handleChange}
58            error={formik.touched.password && Boolean(formik.errors.password)}
59            helperText={formik.touched.password && formik.errors.password}
60          />
61          <Button
62            type="submit"
63            fullWidth
64            variant="contained"
65            sx={{ mt: 3, mb: 2 }}
66          >
67            Sign In
68          </Button>
69        </Box>
70      </Box>
71    </Container>
72  );
73};
74
75export default Login;

Composant de la liste des notes (src/components/NotesList.js)

1import React, { useState, useEffect } from 'react';
2import { 
3  Container, Typography, Box, List, ListItem, ListItemText, 
4  IconButton, TextField, Button, Chip, Dialog, DialogTitle, 
5  DialogContent, DialogActions 
6} from '@mui/material';
7import { Add as AddIcon, Delete as DeleteIcon, Edit as EditIcon } from '@mui/icons-material';
8import { getNotes, deleteNote, getTags, createTag } from '../services/api';
9import { useNavigate } from 'react-router-dom';
10
11const NotesList = () => {
12  const [notes, setNotes] = useState([]);
13  const [tags, setTags] = useState([]);
14  const [search, setSearch] = useState('');
15  const [selectedTags, setSelectedTags] = useState([]);
16  const [openTagDialog, setOpenTagDialog] = useState(false);
17  const [newTagName, setNewTagName] = useState('');
18  const navigate = useNavigate();
19
20  useEffect(() => {
21    fetchNotes();
22    fetchTags();
23  }, [search, selectedTags]);
24
25  const fetchNotes = async () => {
26    try {
27      let searchQuery = search;
28      if (selectedTags.length > 0) {
29        const tagQuery = selectedTags.map(tag => `tags__name=${tag}`).join('&');
30        searchQuery = searchQuery ? `${searchQuery}&${tagQuery}` : tagQuery;
31      }
32      const response = await getNotes(searchQuery);
33      setNotes(response.data);
34    } catch (error) {
35      console.error('Error fetching notes:', error);
36    }
37  };
38
39  const fetchTags = async () => {
40    try {
41      const response = await getTags();
42      setTags(response.data);
43    } catch (error) {
44      console.error('Error fetching tags:', error);
45    }
46  };
47
48  const handleDeleteNote = async (id) => {
49    try {
50      await deleteNote(id);
51      setNotes(notes.filter(note => note.id !== id));
52    } catch (error) {
53      console.error('Error deleting note:', error);
54    }
55  };
56
57  const handleCreateTag = async () => {
58    if (newTagName.trim()) {
59      try {
60        await createTag({ name: newTagName.trim() });
61        setNewTagName('');
62        setOpenTagDialog(false);
63        fetchTags();
64      } catch (error) {
65        console.error('Error creating tag:', error);
66      }
67    }
68  };
69
70  const handleTagSelect = (tagName) => {
71    if (selectedTags.includes(tagName)) {
72      setSelectedTags(selectedTags.filter(tag => tag !== tagName));
73    } else {
74      setSelectedTags([...selectedTags, tagName]);
75    }
76  };
77
78  return (
79    <Container>
80      <Box sx={{ mt: 4, mb: 4 }}>
81        <Typography variant="h4" component="h1" gutterBottom>
82          My Notes
83        </Typography>
84        
85        <Box sx={{ display: 'flex', mb: 2 }}>
86          <TextField
87            fullWidth
88            label="Search notes"
89            variant="outlined"
90            value={search}
91            onChange={(e) => setSearch(e.target.value)}
92            sx={{ mr: 2 }}
93          />
94          <Button 
95            variant="contained" 
96            startIcon={<AddIcon />}
97            onClick={() => navigate('/notes/new')}
98          >
99            New Note
100          </Button>
101        </Box>
102        
103        <Box sx={{ mb: 2, display: 'flex', flexWrap: 'wrap', gap: 1 }}>
104          {tags.map(tag => (
105            <Chip
106              key={tag.id}
107              label={tag.name}
108              onClick={() => handleTagSelect(tag.name)}
109              color={selectedTags.includes(tag.name) ? "primary" : "default"}
110            />
111          ))}
112          <Chip
113            icon={<AddIcon />}
114            label="Add Tag"
115            onClick={() => setOpenTagDialog(true)}
116            variant="outlined"
117          />
118        </Box>
119        
120        <List>
121          {notes.length > 0 ? (
122            notes.map((note) => (
123              <ListItem
124                key={note.id}
125                secondaryAction={
126                  <Box>
127                    <IconButton edge="end" onClick={() => navigate(`/notes/${note.id}`)}>
128                      <EditIcon />
129                    </IconButton>
130                    <IconButton edge="end" onClick={() => handleDeleteNote(note.id)}>
131                      <DeleteIcon />
132                    </IconButton>
133                  </Box>
134                }
135                sx={{ 
136                  border: '1px solid #e0e0e0', 
137                  borderRadius: 1, 
138                  mb: 1,
139                  '&:hover': { bgcolor: 'rgba(0, 0, 0, 0.04)' }
140                }}
141              >
142                <ListItemText
143                  primary={note.title}
144                  secondary={
145                    <>
146                      <Typography component="span" variant="body2" color="text.primary">
147                        {note.content.substring(0, 100)}
148                        {note.content.length > 100 ? '...' : ''}
149                      </Typography>
150                      <Box sx={{ mt: 1, display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
151                        {note.tags.map(tag => (
152                          <Chip key={tag.id} label={tag.name} size="small" />
153                        ))}
154                      </Box>
155                    </>
156                  }
157                />
158              </ListItem>
159            ))
160          ) : (
161            <Typography variant="body1">No notes found. Create your first note!</Typography>
162          )}
163        </List>
164      </Box>
165
166      <Dialog open={openTagDialog} onClose={() => setOpenTagDialog(false)}>
167        <DialogTitle>Create New Tag</DialogTitle>
168        <DialogContent>
169          <TextField
170            autoFocus
171            margin="dense"
172            label="Tag Name"
173            fullWidth
174            variant="outlined"
175            value={newTagName}
176            onChange={(e) => setNewTagName(e.target.value)}
177          />
178        </DialogContent>
179        <DialogActions>
180          <Button onClick={() => setOpenTagDialog(false)}>Cancel</Button>
181          <Button onClick={handleCreateTag}>Create</Button>
182        </DialogActions>
183      </Dialog>
184    </Container>
185  );
186};
187
188export default NotesList;

Composant de l'éditeur de notes (src/components/NoteEditor.js)

1import React, { useState, useEffect } from 'react';
2import { useParams, useNavigate } from 'react-router-dom';
3import { useFormik } from 'formik';
4import * as Yup from 'yup';
5import { 
6  Container, Typography, Box, TextField, Button, 
7  Chip, Select, MenuItem, FormControl, InputLabel, 
8  OutlinedInput, ListItemText, Checkbox
9} from '@mui/material';
10import { getNote, createNote, updateNote, getTags } from '../services/api';
11
12const NoteEditor = () => {
13  const { id } = useParams();
14  const navigate = useNavigate();
15  const [tags, setTags] = useState([]);
16  const isEditing = Boolean(id);
17
18  useEffect(() => {
19    const fetchData = async () => {
20      try {
21        // Fetch available tags
22        const tagsResponse = await getTags();
23        setTags(tagsResponse.data);
24        
25        // If editing, fetch note data
26        if (isEditing) {
27          const noteResponse = await getNote(id);
28          const note = noteResponse.data;
29          formik.setValues({
30            title: note.title,
31            content: note.content,
32            selectedTags: note.tags.map(tag => tag.id)
33          });
34        }
35      } catch (error) {
36        console.error('Error fetching data:', error);
37      }
38    };
39    
40    fetchData();
41  }, [id]);
42
43  const formik = useFormik({
44    initialValues: {
45      title: '',
46      content: '',
47      selectedTags: []
48    },
49    validationSchema: Yup.object({
50      title: Yup.string().required('Title is required'),
51      content: Yup.string().required('Content is required'),
52    }),
53    onSubmit: async (values) => {
54      try {
55        const noteData = {
56          title: values.title,
57          content: values.content,
58          tag_ids: values.selectedTags
59        };
60        
61        if (isEditing) {
62          await updateNote(id, noteData);
63        } else {
64          await createNote(noteData);
65        }
66        navigate('/notes');
67      } catch (error) {
68        console.error('Error saving note:', error);
69      }
70    },
71  });
72
73  return (
74    <Container>
75      <Box sx={{ mt: 4, mb: 4 }}>
76        <Typography variant="h4" component="h1" gutterBottom>
77          {isEditing ? 'Edit Note' : 'Create New Note'}
78        </Typography>
79        
80        <Box component="form" onSubmit={formik.handleSubmit}>
81          <TextField
82            fullWidth
83            id="title"
84            name="title"
85            label="Title"
86            value={formik.values.title}
87            onChange={formik.handleChange}
88            error={formik.touched.title && Boolean(formik.errors.title)}
89            helperText={formik.touched.title && formik.errors.title}
90            margin="normal"
91          />
92          
93          <TextField
94            fullWidth
95            id="content"
96            name="content"
97            label="Content"
98            multiline
99            rows={8}
100            value={formik.values.content}
101            onChange={formik.handleChange}
102            error={formik.touched.content && Boolean(formik.errors.content)}
103            helperText={formik.touched.content && formik.errors.content}
104            margin="normal"
105          />
106          
107          <FormControl fullWidth margin="normal">
108            <InputLabel id="tags-label">Tags</InputLabel>
109            <Select
110              labelId="tags-label"
111              id="selectedTags"
112              name="selectedTags"
113              multiple
114              value={formik.values.selectedTags}
115              onChange={formik.handleChange}
116              input={<OutlinedInput label="Tags" />}
117              renderValue={(selected) => (
118                <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
119                  {selected.map((tagId) => {
120                    const tag = tags.find(t => t.id === tagId);
121                    return tag ? (
122                      <Chip key={tagId} label={tag.name} />
123                    ) : null;
124                  })}
125                </Box>
126              )}
127            >
128              {tags.map((tag) => (
129                <MenuItem key={tag.id} value={tag.id}>
130                  <Checkbox checked={formik.values.selectedTags.indexOf(tag.id) > -1} />
131                  <ListItemText primary={tag.name} />
132                </MenuItem>
133              ))}
134            </Select>
135          </FormControl>
136          
137          <Box sx={{ mt: 3, display: 'flex', gap: 2 }}>
138            <Button 
139              variant="contained" 
140              color="primary" 
141              type="submit"
142            >
143              Save
144            </Button>
145            <Button 
146              variant="outlined" 
147              onClick={() => navigate('/notes')}
148            >
149              Cancel
150            </Button>
151          </Box>
152        </Box>
153      </Box>
154    </Container>
155  );
156};
157
158export default NoteEditor;

5. Routage des applications (src/App.js)

1import React from 'react';
2import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
3import { AuthProvider, useAuth } from './contexts/AuthContext';
4import Login from './components/Login';
5import Register from './components/Register';
6import NotesList from './components/NotesList';
7import NoteEditor from './components/NoteEditor';
8import Navbar from './components/Navbar';
9import { CircularProgress, Box } from '@mui/material';
10
11// Protected route component
12const ProtectedRoute = ({ children }) => {
13  const { user, loading } = useAuth();
14  
15  if (loading) {
16    return (
17      <Box sx={{ display: 'flex', justifyContent: 'center', mt: 4 }}>
18        <CircularProgress />
19      </Box>
20    );
21  }
22  
23  if (!user) {
24    return <Navigate to="/login" />;
25  }
26  
27  return children;
28};
29
30function App() {
31  return (
32    <AuthProvider>
33      <Router>
34        <Navbar />
35        <Routes>
36          <Route path="/login" element={<Login />} />
37          <Route path="/register" element={<Register />} />
38          <Route 
39            path="/notes" 
40            element={
41              <ProtectedRoute>
42                <NotesList />
43              </ProtectedRoute>
44            } 
45          />
46          <Route 
47            path="/notes/new" 
48            element={
49              <ProtectedRoute>
50                <NoteEditor />
51              </ProtectedRoute>
52            } 
53          />
54          <Route 
55            path="/notes/:id" 
56            element={
57              <ProtectedRoute>
58                <NoteEditor />
59              </ProtectedRoute>
60            } 
61          />
62          <Route path="/" element={<Navigate to="/notes" />} />
63        </Routes>
64      </Router>
65    </AuthProvider>
66  );
67}
68
69export default App;

Considérations relatives au déploiement

  1. Déploiement du backend:
    • Utilisez un service tel que Heroku, DigitalOcean ou AWS
    • Configurer des variables d'environnement pour les informations sensibles
    • Configurer CORS pour l'URL du frontend de production
    • Configurer une base de données de production (PostgreSQL recommandé)
  2. Déploiement du frontend:
    • Déployez sur Netlify, Vercel ou des services similaires
    • Configurer les variables d'environnement pour l'URL de l'API
    • Configurez des scripts de construction appropriés
  3. Considérations de sécurité:
    • Mettre en œuvre une limitation de débit
    • Utiliser le protocole HTTPS
    • Définissez des attributs de cookies sécurisés
    • Envisagez d'ajouter l'authentification à deux facteurs pour renforcer la sécurité

Prochaines étapes après le MVP

  1. Fonctionnalités améliorées:
    • Édition de texte enrichi
    • Partage de notes
    • Organisation des dossiers/blocs-notes
    • Support pour les pièces jointes
  2. Améliorations des performances:
    • Pagination pour la liste des notes
    • Stratégies de mise en cache
    • Mise à jour optimiste de l'interface
  3. Caractéristiques commerciales:
    • Niveaux d'abonnement utilisateur
    • Analyses d'utilisation
    • Fonctionnalités de collaboration en équipe

Ce MVP fournit une base solide avec toutes les fonctionnalités de base que vous avez demandées : authentification sécurisée, opérations CRUD pour les notes et fonctionnalités de recherche basées sur des balises.

Explorez les cas d'utilisation associés

Découvrez l'assistant IA de Ninja

Essayez gratuitement dès aujourd'hui. Forfaits à partir de 19$ par mois.