diff --git a/.gitignore b/.gitignore index b96c073..7d5a1a1 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ frontend/.env # vim *.swp *.*~ +/*~ # OS files .DS_Store diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/.idea/arch-vim.iml b/.idea/arch-vim.iml new file mode 100644 index 0000000..204ac0e --- /dev/null +++ b/.idea/arch-vim.iml @@ -0,0 +1,14 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..879b4cf --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..a6411eb --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/workspace.xml b/.idea/workspace.xml new file mode 100644 index 0000000..99fa75d --- /dev/null +++ b/.idea/workspace.xml @@ -0,0 +1,100 @@ + + + + + + + + + + + + + + + + + + { + "lastFilter": { + "state": "OPEN", + "assignee": "Ultramas" + } +} + { + "selectedUrlAndAccountId": { + "url": "https://github.com/ChicoState/arch-vim.git", + "accountId": "130df1b8-71e2-4f25-9651-adb1142a8a39" + } +} + { + "associatedIndex": 4, + "fromUser": false +} + + + + { + "keyToString": { + "ModuleVcsDetector.initialDetectionPerformed": "true", + "RunOnceActivity.ShowReadmeOnStart": "true", + "RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true", + "RunOnceActivity.typescript.service.memoryLimit.init": "true", + "codeWithMe.voiceChat.enabledByDefault": "false", + "git-widget-placeholder": "#31 on not-andy-branch", + "last_opened_file_path": "C:/Users/andy2/Downloads/arch-vim", + "nodejs_package_manager_path": "npm" + } +} + + + + + + + + + + 1777701251187 + + + + + + + + + + + + + \ No newline at end of file diff --git a/backend/api/models.py b/backend/api/models.py index 4b3c308..19c5ef9 100644 --- a/backend/api/models.py +++ b/backend/api/models.py @@ -1,11 +1,116 @@ +from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models -# Create your models here. +from colorfield.fields import ColorField + from django.contrib.auth.models import User -class UserProgress(models.Model): +class UserProfile(models.Model): user = models.OneToOneField(User, on_delete=models.CASCADE) + + +class UserProgress(models.Model): + user = models.OneToOneField(UserProfile, on_delete=models.CASCADE) data = models.JSONField(default=dict) def __str__(self): - return f"{self.user.username}'s level progress" \ No newline at end of file + return f"{self.user.username}'s level progress" + + + +class Level(models.Model): + COLOR_PALETTE = [ + ("#FFFFFF", "white",), + ("#000000", "black",), + ] + level = models.IntegerField(default=1, blank=True, null=True) + level_name = models.CharField(max_length=200) + experience = models.IntegerField(default=0, blank=True, null=True) + icon = models.ImageField(blank=True, null=True) + color_wheel = ColorField(samples=COLOR_PALETTE, blank=True, null=True) + color = models.CharField(max_length=500, blank=True, null=True, help_text="Comma-separated hex colors for gradient") + display_name = models.CharField( + max_length=300, + blank=True, + editable=False, + verbose_name="Display Name (with Roman if needed)" + ) + + is_active = models.IntegerField( + default=1, + blank=True, + null=True, + help_text='1->Active, 0->Inactive', + choices=((1, 'Active'), (0, 'Inactive')), + verbose_name="Set active?" + ) + + def __str__(self): + return f"{self.level_name} (Level {self.level})" + +#essentially treated like a through model branching together users & levels +class User_Level(models.Model): + COLOR_PALETTE = [ + ("#FFFFFF", "white",), + ("#000000", "black",), + ] + level = models.ForeignKey(Level, on_delete=models.CASCADE) + min_accuracy = models.FloatField(validators=[MaxValueValidator(100)]) + max_keystrokes = models.IntegerField(blank=True, null=True) + stars = models.IntegerField(validators=[MinValueValidator(1), MaxValueValidator(1)]) #create a function to determine accuracy & time, pulled from frontend data + is_active = models.IntegerField( + default=1, + blank=True, + null=True, + help_text='1->Active, 0->Inactive', + choices=((1, 'Active'), (0, 'Inactive')), + verbose_name="Set active?" + ) + + def __str__(self): + return f"{self.level_name} (Level {self.level})" + + class Meta(): + verbose_name_plural = "User's Levels" + + + +class UserLevelInstance(models.Model): + user = models.ForeignKey( + User, + on_delete=models.CASCADE, + related_name="level_attempts" + ) + level = models.ForeignKey( + Level, + on_delete=models.CASCADE, + related_name="user_instances" + ) + max_time = models.FloatField( + blank=True, null=True, + help_text="Time taken (seconds) to complete the level" + ) + stroke_count = models.IntegerField( + blank=True, null=True, + help_text="Number of keystrokes used during the attempt" + ) + accuracy = models.FloatField( + blank=True, null=True, + help_text="Accuracy percentage (0–100) submitted by the frontend" + ) + completed = models.BooleanField(default=False) + stars_earned = models.IntegerField( + default=0, + validators=[MinValueValidator(0), MaxValueValidator(3)], + help_text="0=not completed, 1=completed, 2=+accuracy, 3=+accuracy & time" + ) + attempted_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ["-stars_earned", "-attempted_at"] + + def __str__(self): + return ( + f"{self.user.username} → Level {self.level_id} | " + f"{'✓' if self.completed else '✗'} | ⭐{self.stars_earned}" + ) \ No newline at end of file diff --git a/backend/api/serializers.py b/backend/api/serializers.py new file mode 100644 index 0000000..39bd267 --- /dev/null +++ b/backend/api/serializers.py @@ -0,0 +1,17 @@ +from rest_framework import serializers +from .models import Level + +class LevelSerializer(serializers.ModelSerializer): + class Meta: + model = Level + fields = [ + 'id', + 'level', + 'level_name', + 'display_name', + 'experience', + 'color', + 'color_wheel', + 'icon', + 'is_active', + ] \ No newline at end of file diff --git a/backend/api/urls.py b/backend/api/urls.py index f919a45..38d7c81 100644 --- a/backend/api/urls.py +++ b/backend/api/urls.py @@ -1,9 +1,13 @@ from django.urls import path from . import views +from .views import StarView, ProgressView urlpatterns = [ path('auth/register/', views.RegisterView.as_view()), path('auth/me/', views.UserDetailView.as_view()), - path('progress/', views.get_progress), + path('progress/', views.UserProgress), path('progress/save/', views.save_level), + path("api/stars//", StarView.as_view(), name="star-view"), + path("api/progress/", ProgressView.as_view(), name="progress"), + path("api/progress/save/", ProgressView.as_view(), name="progress-save"), ] \ No newline at end of file diff --git a/backend/api/views.py b/backend/api/views.py index 81078d5..268dd8d 100644 --- a/backend/api/views.py +++ b/backend/api/views.py @@ -1,3 +1,5 @@ +from django.http import JsonResponse +from django.views.generic import ListView from rest_framework import generics, permissions, status from rest_framework.response import Response from rest_framework.decorators import api_view, permission_classes @@ -50,12 +52,6 @@ def get(self, request): }) -@api_view(['GET']) -@permission_classes([permissions.IsAuthenticated]) -def get_progress(request): - progress, _ = UserProgress.objects.get_or_create(user=request.user) - return Response(progress.data) - @api_view(['POST']) @permission_classes([permissions.IsAuthenticated]) @@ -63,4 +59,198 @@ def save_level(request): progress, _ = UserProgress.objects.get_or_create(user=request.user) progress.data = request.data progress.save() - return Response({'status': 'saved'}) \ No newline at end of file + return Response({'status': 'saved'}) + +from django.contrib.auth.mixins import LoginRequiredMixin +from django.utils.decorators import method_decorator +from django.views.decorators.csrf import csrf_exempt +import json + +from .models import User_Level, UserLevelInstance + + +@method_decorator(csrf_exempt, name="dispatch") +class StarView(LoginRequiredMixin, ListView): + """ + GET /api/stars// → return the user's best attempt for that level + POST /api/stars// → receive frontend performance data, compute stars, + save UserLevelInstance, return result + """ + + def _compute_stars(self, config: User_Level, completed: bool, accuracy: float, time_taken: float) -> int: + """ + Star logic: + 0 → not completed + 1 → completed + 2 → completed + accuracy met + 3 → completed + accuracy met + time met + """ + if not completed: + return 0 + + accuracy_met = (accuracy is not None) and (accuracy >= config.min_accuracy) + time_met = ( + config.max_time is not None + and time_taken is not None + and time_taken <= config.max_time + ) + + if accuracy_met and time_met: + return 3 + if accuracy_met: + return 2 + return 1 + + def get(self, request, level_id): + """Return the user's best existing attempt for a level. + + + Expects JSON body: + { + "completed": true, + "accuracy": 92.5, + "time_taken": 45.3, + "stroke_count": 134 + } + """ + try: + body = json.loads(request.body) + except json.JSONDecodeError: + return JsonResponse({"error": "Invalid JSON"}, status=400) + + completed = bool(body.get("completed", False)) + accuracy = body.get("accuracy") + time_taken = body.get("time_taken") + stroke_count = body.get("stroke_count") + + + config = ( + User_Level.objects + .filter(level_id=level_id, is_active=1) + .first() + ) + if not config: + return JsonResponse({"error": "No active config found for this level"}, status=404) + + stars = self._compute_stars(config, completed, accuracy, time_taken) + + + existing = ( + UserLevelInstance.objects + .filter(user=request.user, level_id=level_id) + .order_by("-stars_earned") + .first() + ) + if not existing or stars >= existing.stars_earned: + UserLevelInstance.objects.update_or_create( + user=request.user, + level_id=level_id, + defaults={ + "max_time": time_taken, + "stroke_count": stroke_count, + "accuracy": accuracy, + "completed": completed, + "stars_earned": stars, + }, + ) + + return JsonResponse({ + "stars_earned": stars, + "completed": completed, + "accuracy": accuracy, + "time_taken": time_taken, + }) + + +@method_decorator(csrf_exempt, name="dispatch") +class ProgressView(LoginRequiredMixin, ListView): + """ + GET /api/progress/ → all level completions for the current user + POST /api/progress/save/ → thin wrapper kept for backwards-compat with progress.js + """ + + def get(self, request): + instances = UserLevelInstance.objects.filter(user=request.user) + data = { + str(inst.level_id): { + "stars_earned": inst.stars_earned, + "completed": inst.completed, + } + for inst in instances + } + return JsonResponse(data) + + def post(self, request): + """ + Accepts the same shape as the existing saveProgress() call in progress.js. + Delegates to StarView logic if performance data is present, + otherwise just records completion. + """ + try: + body = json.loads(request.body) + except json.JSONDecodeError: + return JsonResponse({"error": "Invalid JSON"}, status=400) + + level_id = body.get("level_id") + if not level_id: + return JsonResponse({"error": "level_id required"}, status=400) + + request._body = request.body + return StarView.as_view()(request, level_id=level_id) + + + +from django.http import JsonResponse +from django.views.generic import ListView +from rest_framework import generics, permissions, status +from rest_framework.response import Response +from rest_framework.decorators import api_view, permission_classes +from django.contrib.auth.models import User +from rest_framework_simplejwt.tokens import RefreshToken + + +class RegisterView(generics.CreateAPIView): + permission_classes = [permissions.AllowAny] + + def post(self, request): + username = request.data.get('username') + password = request.data.get('password') + email = request.data.get('email', '') + + if not username or not password: + return Response( + {'error': 'Username and password are required'}, + status=status.HTTP_400_BAD_REQUEST + ) + + if User.objects.filter(username=username).exists(): + return Response( + {'error': 'Username already taken'}, + status=status.HTTP_400_BAD_REQUEST + ) + + user = User.objects.create_user( + username=username, + password=password, + email=email + ) + refresh = RefreshToken.for_user(user) + + return Response({ + 'access': str(refresh.access_token), + 'refresh': str(refresh), + }, status=status.HTTP_201_CREATED) + + +class UserDetailView(generics.RetrieveAPIView): + permission_classes = [permissions.IsAuthenticated] + + def get(self, request): + return Response({ + 'id': request.user.id, + 'username': request.user.username, + 'email': request.user.email, + }) + + + diff --git a/backend/config/models.py b/backend/config/models.py index e69de29..0842ea4 100644 --- a/backend/config/models.py +++ b/backend/config/models.py @@ -0,0 +1,64 @@ +from django.core.validators import MaxValueValidator, MinValueValidator +from django.db import models + +from colorfield.fields import ColorField + +from django.contrib.auth.models import User + + + + +class Level(models.Model): + COLOR_PALETTE = [ + ("#FFFFFF", "white",), + ("#000000", "black",), + ] + level = models.IntegerField(default=1, blank=True, null=True) + level_name = models.CharField(max_length=200) + experience = models.IntegerField(default=0, blank=True, null=True) + icon = models.ImageField(blank=True, null=True) + color_wheel = ColorField(samples=COLOR_PALETTE, blank=True, null=True) + color = models.CharField(max_length=500, blank=True, null=True, help_text="Comma-separated hex colors for gradient") + display_name = models.CharField( + max_length=300, + blank=True, + editable=False, + verbose_name="Display Name (with Roman if needed)" + ) + + is_active = models.IntegerField( + default=1, + blank=True, + null=True, + help_text='1->Active, 0->Inactive', + choices=((1, 'Active'), (0, 'Inactive')), + verbose_name="Set active?" + ) + + def __str__(self): + return f"{self.level_name} (Level {self.level})" + +#essentially treated like a through model branching together users & levels +class User_Level(models.Model): + COLOR_PALETTE = [ + ("#FFFFFF", "white",), + ("#000000", "black",), + ] + level = models.ForeignKey(Level, on_delete=models.CASCADE) + min_accuracy = models.FloatField(validators=[MaxValueValidator(100)]) + max_keystrokes = models.IntegerField(blank=True, null=True) + stars = models.IntegerField(validators=[MinValueValidator(1), MaxValueValidator(1)]) #create a function to determine accuracy & time, pulled from frontend data + is_active = models.IntegerField( + default=1, + blank=True, + null=True, + help_text='1->Active, 0->Inactive', + choices=((1, 'Active'), (0, 'Inactive')), + verbose_name="Set active?" + ) + + def __str__(self): + return f"{self.level_name} (Level {self.level})" + + class Meta(): + verbose_name_plural = "User's Levels" diff --git a/backend/config/urls.py b/backend/config/urls.py index 1cdb2f2..2935097 100644 --- a/backend/config/urls.py +++ b/backend/config/urls.py @@ -2,6 +2,7 @@ from django.urls import path, include from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView + urlpatterns = [ path('admin/', admin.site.urls), path('api/auth/login/', TokenObtainPairView.as_view()), diff --git a/backend/config/views.py b/backend/config/views.py index e69de29..ac024b5 100644 --- a/backend/config/views.py +++ b/backend/config/views.py @@ -0,0 +1,62 @@ +from django.http import JsonResponse +from django.views.generic import ListView +from rest_framework import generics, permissions, status +from rest_framework.response import Response +from rest_framework.decorators import api_view, permission_classes +from django.contrib.auth.models import User +from rest_framework_simplejwt.tokens import RefreshToken + + +class RegisterView(generics.CreateAPIView): + permission_classes = [permissions.AllowAny] + + def post(self, request): + username = request.data.get('username') + password = request.data.get('password') + email = request.data.get('email', '') + + if not username or not password: + return Response( + {'error': 'Username and password are required'}, + status=status.HTTP_400_BAD_REQUEST + ) + + if User.objects.filter(username=username).exists(): + return Response( + {'error': 'Username already taken'}, + status=status.HTTP_400_BAD_REQUEST + ) + + user = User.objects.create_user( + username=username, + password=password, + email=email + ) + refresh = RefreshToken.for_user(user) + + return Response({ + 'access': str(refresh.access_token), + 'refresh': str(refresh), + }, status=status.HTTP_201_CREATED) + + +class UserDetailView(generics.RetrieveAPIView): + permission_classes = [permissions.IsAuthenticated] + + def get(self, request): + return Response({ + 'id': request.user.id, + 'username': request.user.username, + 'email': request.user.email, + }) + + + + +from django.contrib.auth.mixins import LoginRequiredMixin +from django.utils.decorators import method_decorator +from django.views.decorators.csrf import csrf_exempt +import json + +from .models import Level, User_Level + diff --git a/backend/requirements.txt b/backend/requirements.txt index e77af21..d84d58e 100644 Binary files a/backend/requirements.txt and b/backend/requirements.txt differ diff --git a/frontend/src/components/loadprogress.js b/frontend/src/components/loadprogress.js new file mode 100644 index 0000000..0d381fc --- /dev/null +++ b/frontend/src/components/loadprogress.js @@ -0,0 +1,47 @@ +import api from './api.js'; + +/** + * Load all level progress for the current user. + * Returns: { "1": { stars_earned: 3, completed: true }, ... } + */ +export async function loadProgress() { + try { + const res = await api.get('/api/progress/'); + return res.data; + } catch { + return {}; + } +} + +/** + * Save a level attempt and get back the stars awarded. + * + * @param {Object} data + * @param {number} data.level_id - e.g. 1 + * @param {boolean} data.completed - did the user finish? + * @param {number} data.accuracy - 0–100 float + * @param {number} data.time_taken - seconds (float) + * @param {number} data.stroke_count - keystroke count (int) + * @returns {{ stars_earned, completed, accuracy, time_taken }} + */ +export async function saveProgress(data) { + try { + const res = await api.post(`/api/stars/${data.level_id}/`, data); + return res.data; // ← includes stars_earned so the UI can react + } catch (err) { + console.error('Failed to save progress', err); + return null; + } +} + +/** + * Fetch the best stars earned for a single level (for the level select screen). + */ +export async function getLevelStars(levelId) { + try { + const res = await api.get(`/api/stars/${levelId}/`); + return res.data.stars_earned ?? 0; + } catch { + return 0; + } +} \ No newline at end of file