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
+
+
+ 1777701251187
+
+
+
+ 1777703832844
+
+
+
+ 1777703832844
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ 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