Compare commits

...

4 Commits

Author SHA1 Message Date
Flatlogic Bot
499b68d454 Beta Final Dark 2026-02-05 23:18:20 +00:00
Flatlogic Bot
9b6e5f4d03 Beta Final 2026-02-04 23:30:33 +00:00
Flatlogic Bot
77d103ab40 Autosave: 20260204-231352 2026-02-04 23:13:52 +00:00
Flatlogic Bot
88ba420f9e Autosave: 20260204-202734 2026-02-04 20:27:35 +00:00
53 changed files with 2737 additions and 179 deletions

View File

@ -155,6 +155,10 @@ STATICFILES_DIRS = [
BASE_DIR / 'node_modules', BASE_DIR / 'node_modules',
] ]
# Media files (Uploaded by users)
MEDIA_URL = '/media/'
MEDIA_ROOT = BASE_DIR / 'media'
# Email # Email
EMAIL_BACKEND = os.getenv( EMAIL_BACKEND = os.getenv(
"EMAIL_BACKEND", "EMAIL_BACKEND",
@ -179,4 +183,4 @@ if EMAIL_USE_SSL:
# Default primary key field type # Default primary key field type
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field # https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'

View File

@ -25,5 +25,6 @@ urlpatterns = [
] ]
if settings.DEBUG: if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
urlpatterns += static("/assets/", document_root=settings.BASE_DIR / "assets") urlpatterns += static("/assets/", document_root=settings.BASE_DIR / "assets")
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)

Binary file not shown.

Binary file not shown.

Binary file not shown.

174
core/forms.py Normal file
View File

@ -0,0 +1,174 @@
from django import forms
from .models import WardrobeItem, Accessory, Outfit, Category, OutfitFolder, UserProfile
class UserProfileForm(forms.ModelForm):
class Meta:
model = UserProfile
fields = ['avatar']
widgets = {
'avatar': forms.FileInput(attrs={'class': 'form-control bg-dark text-white border-secondary'}),
}
class WardrobeItemForm(forms.ModelForm):
main_category = forms.ModelChoiceField(
queryset=Category.objects.filter(item_type='wardrobe', parent=None),
required=False,
empty_label="Select Main Category",
widget=forms.Select(attrs={'class': 'form-select bg-dark text-white border-secondary', 'id': 'id_main_category'})
)
new_main_category = forms.CharField(
max_length=100, required=False, label="Or add new main category",
widget=forms.TextInput(attrs={'class': 'form-control bg-dark text-white border-secondary', 'placeholder': 'New main category...'})
)
new_subcategory = forms.CharField(
max_length=100, required=False, label="Or add new subcategory",
widget=forms.TextInput(attrs={'class': 'form-control bg-dark text-white border-secondary', 'placeholder': 'New subcategory...'})
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['category'].queryset = Category.objects.none()
self.fields['category'].required = False
self.fields['category'].label = "Subcategory"
self.fields['name'].required = False
if 'main_category' in self.data:
try:
main_category_id = int(self.data.get('main_category'))
self.fields['category'].queryset = Category.objects.filter(parent_id=main_category_id).order_by('name')
except (ValueError, TypeError):
pass
elif self.instance.pk and self.instance.category and self.instance.category.parent:
self.fields['main_category'].initial = self.instance.category.parent
self.fields['category'].queryset = Category.objects.filter(parent=self.instance.category.parent).order_by('name')
class Meta:
model = WardrobeItem
fields = ['name', 'category', 'season', 'image']
widgets = {
'name': forms.TextInput(attrs={'class': 'form-control bg-dark text-white border-secondary', 'placeholder': 'Item name (optional)...'}),
'category': forms.Select(attrs={'class': 'form-select bg-dark text-white border-secondary', 'id': 'id_subcategory'}),
'season': forms.Select(attrs={'class': 'form-select bg-dark text-white border-secondary'}),
'image': forms.FileInput(attrs={'class': 'form-control bg-dark text-white border-secondary'}),
}
def save(self, commit=True):
instance = super().save(commit=False)
new_main = self.cleaned_data.get('new_main_category')
new_sub = self.cleaned_data.get('new_subcategory')
main_cat = self.cleaned_data.get('main_category')
if new_main:
parent_cat, _ = Category.objects.get_or_create(name=new_main, item_type='wardrobe', parent=None)
if new_sub:
child_cat, _ = Category.objects.get_or_create(name=new_sub, item_type='wardrobe', parent=parent_cat)
instance.category = child_cat
else:
instance.category = parent_cat
elif main_cat:
if new_sub:
child_cat, _ = Category.objects.get_or_create(name=new_sub, item_type='wardrobe', parent=main_cat)
instance.category = child_cat
# else category field itself handles it if selected
if commit:
instance.save()
return instance
class AccessoryForm(forms.ModelForm):
main_category = forms.ModelChoiceField(
queryset=Category.objects.filter(item_type='accessory', parent=None),
required=False,
empty_label="Select Main Category",
widget=forms.Select(attrs={'class': 'form-select bg-dark text-white border-secondary', 'id': 'id_main_category'})
)
new_main_category = forms.CharField(
max_length=100, required=False, label="Or add new main category",
widget=forms.TextInput(attrs={'class': 'form-control bg-dark text-white border-secondary', 'placeholder': 'New main category...'})
)
new_subcategory = forms.CharField(
max_length=100, required=False, label="Or add new subcategory",
widget=forms.TextInput(attrs={'class': 'form-control bg-dark text-white border-secondary', 'placeholder': 'New subcategory...'})
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['category'].queryset = Category.objects.none()
self.fields['category'].required = False
self.fields['category'].label = "Subcategory"
self.fields['name'].required = False
if 'main_category' in self.data:
try:
main_category_id = int(self.data.get('main_category'))
self.fields['category'].queryset = Category.objects.filter(parent_id=main_category_id).order_by('name')
except (ValueError, TypeError):
pass
elif self.instance.pk and self.instance.category and self.instance.category.parent:
self.fields['main_category'].initial = self.instance.category.parent
self.fields['category'].queryset = Category.objects.filter(parent=self.instance.category.parent).order_by('name')
class Meta:
model = Accessory
fields = ['name', 'category', 'season', 'image']
widgets = {
'name': forms.TextInput(attrs={'class': 'form-control bg-dark text-white border-secondary', 'placeholder': 'Accessory name (optional)...'}),
'category': forms.Select(attrs={'class': 'form-select bg-dark text-white border-secondary', 'id': 'id_subcategory'}),
'season': forms.Select(attrs={'class': 'form-select bg-dark text-white border-secondary'}),
'image': forms.FileInput(attrs={'class': 'form-control bg-dark text-white border-secondary'}),
}
def save(self, commit=True):
instance = super().save(commit=False)
new_main = self.cleaned_data.get('new_main_category')
new_sub = self.cleaned_data.get('new_subcategory')
main_cat = self.cleaned_data.get('main_category')
if new_main:
parent_cat, _ = Category.objects.get_or_create(name=new_main, item_type='accessory', parent=None)
if new_sub:
child_cat, _ = Category.objects.get_or_create(name=new_sub, item_type='accessory', parent=parent_cat)
instance.category = child_cat
else:
instance.category = parent_cat
elif main_cat:
if new_sub:
child_cat, _ = Category.objects.get_or_create(name=new_sub, item_type='accessory', parent=main_cat)
instance.category = child_cat
if commit:
instance.save()
return instance
class OutfitForm(forms.ModelForm):
new_folder = forms.CharField(max_length=100, required=False, label="Or add new folder/group",
widget=forms.TextInput(attrs={'class': 'form-control bg-dark text-white border-secondary', 'placeholder': 'Enter new group name...'}))
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['name'].required = False
self.fields['season'].required = False
self.fields['items'].required = False
self.fields['accessories'].required = False
class Meta:
model = Outfit
fields = ['name', 'season', 'items', 'accessories', 'folder']
widgets = {
'name': forms.TextInput(attrs={'class': 'form-control bg-dark text-white border-secondary', 'placeholder': 'Outfit name (optional)...'}),
'season': forms.Select(attrs={'class': 'form-select bg-dark text-white border-secondary'}),
'items': forms.SelectMultiple(attrs={'class': 'form-select d-none'}),
'accessories': forms.SelectMultiple(attrs={'class': 'form-select d-none'}),
'folder': forms.Select(attrs={'class': 'form-select bg-dark text-white border-secondary'}),
}
def save(self, commit=True):
instance = super().save(commit=False)
new_folder_name = self.cleaned_data.get('new_folder')
if new_folder_name:
folder, _ = OutfitFolder.objects.get_or_create(name=new_folder_name)
instance.folder = folder
if commit:
instance.save()
self.save_m2m()
return instance

View File

Binary file not shown.

View File

View File

@ -0,0 +1,44 @@
from django.core.management.base import BaseCommand
from core.models import Category
class Command(BaseCommand):
help = 'Prepopulate hierarchical categories'
def handle(self, *args, **options):
# Full Reset
Category.objects.all().delete()
hierarchy = {
'wardrobe': {
'Jackets': ['Jackets', 'Coats', 'Zip-Ups'],
'Shirts': ['Hoodies', 'Pullover', 'Sweater', 'Turtlenecks', 'Shirts'],
'T-Shirts': ['T-Shirts', 'Armless Shirts', 'Tanktops'],
'Pants': ['Jeans', 'Pants', 'Cargo Pants', 'Linen'],
'Shorts': ['Shorts', 'Jorts'],
'Suit': ['Suits', 'Suit Jackets', 'Shirts', 'Suit Pants', 'Suit Shoes'],
'Shoes': ['Shoes', 'Sneakers', 'Boots', 'Suit Shoes'],
},
'accessory': {
'Jewelry': ['Rings', 'Pant Chains'],
'Headwear': ['Headwear'],
}
}
for item_type, parents in hierarchy.items():
for parent_name, children in parents.items():
parent_cat = Category.objects.create(
name=parent_name,
item_type=item_type,
parent=None,
is_preset=True
)
for child_name in children:
Category.objects.create(
name=child_name,
item_type=item_type,
parent=parent_cat,
is_preset=True
)
self.stdout.write(self.style.SUCCESS('Successfully reset and prepopulated hierarchical categories'))

View File

@ -0,0 +1,81 @@
# Generated by Django 5.2.7 on 2026-02-04 11:49
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Category',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100)),
('item_type', models.CharField(choices=[('wardrobe', 'Wardrobe'), ('accessory', 'Accessory')], max_length=20)),
('is_preset', models.BooleanField(default=False)),
],
options={
'verbose_name_plural': 'Categories',
},
),
migrations.CreateModel(
name='OutfitFolder',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100)),
],
),
migrations.CreateModel(
name='Accessory',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('image', models.ImageField(upload_to='accessories/')),
('date_added', models.DateTimeField(auto_now_add=True)),
('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='accessories', to='core.category')),
],
options={
'verbose_name_plural': 'Accessories',
},
),
migrations.CreateModel(
name='Outfit',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(blank=True, max_length=255, null=True)),
('season', models.CharField(choices=[('summer', 'Summer'), ('spring', 'Spring'), ('autumn', 'Autumn'), ('winter', 'Winter')], max_length=20)),
('date_created', models.DateTimeField(auto_now_add=True)),
('accessories', models.ManyToManyField(related_name='outfits', to='core.accessory')),
('folder', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='outfits', to='core.outfitfolder')),
],
),
migrations.CreateModel(
name='CalendarAssignment',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('date', models.DateField(unique=True)),
('outfit', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='assignments', to='core.outfit')),
],
),
migrations.CreateModel(
name='WardrobeItem',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('image', models.ImageField(upload_to='wardrobe/')),
('date_added', models.DateTimeField(auto_now_add=True)),
('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='wardrobe_items', to='core.category')),
],
),
migrations.AddField(
model_name='outfit',
name='items',
field=models.ManyToManyField(related_name='outfits', to='core.wardrobeitem'),
),
]

View File

@ -0,0 +1,69 @@
# Generated by Django 5.2.7 on 2026-02-04 16:42
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='accessory',
name='season',
field=models.CharField(blank=True, choices=[('summer', 'Summer'), ('spring', 'Spring'), ('autumn', 'Autumn'), ('winter', 'Winter')], max_length=20, null=True),
),
migrations.AddField(
model_name='category',
name='parent',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='core.category'),
),
migrations.AddField(
model_name='outfitfolder',
name='parent',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='core.outfitfolder'),
),
migrations.AddField(
model_name='wardrobeitem',
name='season',
field=models.CharField(blank=True, choices=[('summer', 'Summer'), ('spring', 'Spring'), ('autumn', 'Autumn'), ('winter', 'Winter')], max_length=20, null=True),
),
migrations.AlterField(
model_name='accessory',
name='category',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='accessories', to='core.category'),
),
migrations.AlterField(
model_name='accessory',
name='name',
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AlterField(
model_name='outfit',
name='accessories',
field=models.ManyToManyField(blank=True, related_name='outfits', to='core.accessory'),
),
migrations.AlterField(
model_name='outfit',
name='items',
field=models.ManyToManyField(blank=True, related_name='outfits', to='core.wardrobeitem'),
),
migrations.AlterField(
model_name='outfit',
name='season',
field=models.CharField(blank=True, choices=[('summer', 'Summer'), ('spring', 'Spring'), ('autumn', 'Autumn'), ('winter', 'Winter')], max_length=20, null=True),
),
migrations.AlterField(
model_name='wardrobeitem',
name='category',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='wardrobe_items', to='core.category'),
),
migrations.AlterField(
model_name='wardrobeitem',
name='name',
field=models.CharField(blank=True, max_length=255, null=True),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.2.7 on 2026-02-04 17:20
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0002_accessory_season_category_parent_outfitfolder_parent_and_more'),
]
operations = [
migrations.AddField(
model_name='outfitfolder',
name='is_preset',
field=models.BooleanField(default=False),
),
]

View File

@ -0,0 +1,24 @@
# Generated by Django 5.2.7 on 2026-02-04 23:00
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0003_outfitfolder_is_preset'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='UserProfile',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('avatar', models.ImageField(blank=True, null=True, upload_to='avatars/')),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='profile', to=settings.AUTH_USER_MODEL)),
],
),
]

View File

@ -1,3 +1,126 @@
from django.db import models from django.db import models
from django.contrib.auth.models import User
from django.db.models.signals import post_save
from django.dispatch import receiver
import os
from .utils import process_clothing_image
# Create your models here. class UserProfile(models.Model):
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='profile')
avatar = models.ImageField(upload_to='avatars/', null=True, blank=True)
def __str__(self):
return f"{self.user.username}'s Profile"
@receiver(post_save, sender=User)
def create_user_profile(sender, instance, created, **kwargs):
if created:
UserProfile.objects.get_or_create(user=instance)
@receiver(post_save, sender=User)
def save_user_profile(sender, instance, **kwargs):
if not hasattr(instance, 'profile'):
UserProfile.objects.create(user=instance)
instance.profile.save()
class Category(models.Model):
TYPE_CHOICES = [
('wardrobe', 'Wardrobe'),
('accessory', 'Accessory'),
]
name = models.CharField(max_length=100)
item_type = models.CharField(max_length=20, choices=TYPE_CHOICES)
is_preset = models.BooleanField(default=False)
parent = models.ForeignKey('self', on_delete=models.CASCADE, null=True, blank=True, related_name='children')
def __get_full_path(self):
if self.parent:
return f"{self.parent.__get_full_path()} > {self.name}"
return self.name
def __str__(self):
return f"{self.__get_full_path()} ({self.get_item_type_display()})"
class Meta:
verbose_name_plural = "Categories"
class WardrobeItem(models.Model):
SEASON_CHOICES = [
('summer', 'Summer'),
('spring', 'Spring'),
('autumn', 'Autumn'),
('winter', 'Winter'),
]
name = models.CharField(max_length=255, blank=True, null=True)
category = models.ForeignKey(Category, on_delete=models.SET_NULL, null=True, blank=True, related_name='wardrobe_items')
image = models.ImageField(upload_to='wardrobe/')
season = models.CharField(max_length=20, choices=SEASON_CHOICES, blank=True, null=True)
date_added = models.DateTimeField(auto_now_add=True)
def save(self, *args, **kwargs):
is_new = self.pk is None
super().save(*args, **kwargs)
if is_new and self.image:
process_clothing_image(self.image.path)
def __str__(self):
return self.name or f"Wardrobe Item {self.id}"
class Accessory(models.Model):
SEASON_CHOICES = [
('summer', 'Summer'),
('spring', 'Spring'),
('autumn', 'Autumn'),
('winter', 'Winter'),
]
name = models.CharField(max_length=255, blank=True, null=True)
category = models.ForeignKey(Category, on_delete=models.SET_NULL, null=True, blank=True, related_name='accessories')
image = models.ImageField(upload_to='accessories/')
season = models.CharField(max_length=20, choices=SEASON_CHOICES, blank=True, null=True)
date_added = models.DateTimeField(auto_now_add=True)
def save(self, *args, **kwargs):
is_new = self.pk is None
super().save(*args, **kwargs)
if is_new and self.image:
process_clothing_image(self.image.path)
def __str__(self):
return self.name or f"Accessory {self.id}"
class Meta:
verbose_name_plural = "Accessories"
class OutfitFolder(models.Model):
name = models.CharField(max_length=100)
parent = models.ForeignKey('self', on_delete=models.CASCADE, null=True, blank=True, related_name='children')
is_preset = models.BooleanField(default=False)
def __str__(self):
if self.parent:
return f"{self.parent} > {self.name}"
return self.name
class Outfit(models.Model):
SEASON_CHOICES = [
('summer', 'Summer'),
('spring', 'Spring'),
('autumn', 'Autumn'),
('winter', 'Winter'),
]
name = models.CharField(max_length=255, blank=True, null=True)
season = models.CharField(max_length=20, choices=SEASON_CHOICES, blank=True, null=True)
folder = models.ForeignKey(OutfitFolder, on_delete=models.SET_NULL, null=True, blank=True, related_name='outfits')
items = models.ManyToManyField(WardrobeItem, related_name='outfits', blank=True)
accessories = models.ManyToManyField(Accessory, related_name='outfits', blank=True)
date_created = models.DateTimeField(auto_now_add=True)
def __str__(self):
return self.name or f"Outfit {self.id}"
class CalendarAssignment(models.Model):
date = models.DateField(unique=True)
outfit = models.ForeignKey(Outfit, on_delete=models.CASCADE, related_name='assignments')
def __str__(self):
return f"{self.date}: {self.outfit}"

View File

@ -1,25 +1,138 @@
{% load static %}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>{% block title %}Knowledge Base{% endblock %}</title> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
{% if project_description %} <meta name="theme-color" content="#121212">
<meta name="description" content="{{ project_description }}"> <meta name="apple-mobile-web-app-capable" content="yes">
<meta property="og:description" content="{{ project_description }}"> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta property="twitter:description" content="{{ project_description }}">
{% endif %} <title>{% block title %}Wardrobe Planner{% endblock %}</title>
{% if project_image_url %} <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<meta property="og:image" content="{{ project_image_url }}"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css">
<meta property="twitter:image" content="{{ project_image_url }}"> <link href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;600&family=Inter:wght@400;500&display=swap" rel="stylesheet">
{% endif %} <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
{% load static %} <style>
<link rel="stylesheet" href="{% static 'css/custom.css' %}?v={{ deployment_timestamp }}"> :root {
{% block head %}{% endblock %} --bg-dark: #121212;
--surface-dark: #1E1E1E;
--primary-accent: #BB86FC;
--accent-lavender: #BB86FC;
--secondary-accent: #03DAC6;
--text-main: #E0E0E0;
--text-dim: #9E9E9E;
}
body {
background-color: var(--bg-dark);
color: var(--text-main);
font-family: 'Inter', sans-serif;
padding-bottom: 80px;
/* Support for notch/safe areas */
padding-top: env(safe-area-inset-top);
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
}
h1, h2, h3, h4, h5, h6 {
font-family: 'Poppins', sans-serif;
font-weight: 600;
}
.surface {
background-color: var(--surface-dark);
border-radius: 16px;
padding: 15px;
border: 1px solid rgba(255, 255, 255, 0.05);
}
.glass-card {
background: rgba(30, 30, 30, 0.6);
backdrop-filter: blur(10px);
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.accent-lavender { color: var(--primary-accent); }
.accent-teal { color: var(--secondary-accent); }
.bottom-nav {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background-color: var(--surface-dark);
display: flex;
justify-content: space-around;
padding: 12px 0;
padding-bottom: calc(12px + env(safe-area-inset-bottom));
border-top: 1px solid rgba(255, 255, 255, 0.1);
z-index: 1000;
}
.nav-item {
color: var(--text-dim);
text-decoration: none;
font-size: 0.75rem;
display: flex;
flex-direction: column;
align-items: center;
transition: color 0.3s ease;
}
.nav-item.active {
color: var(--primary-accent);
}
.nav-item i {
font-size: 1.3rem;
margin-bottom: 4px;
}
.season-icon {
font-size: 1.5rem;
cursor: pointer;
opacity: 0.5;
transition: opacity 0.3s;
}
.season-icon.active {
opacity: 1;
}
.btn-accent {
background-color: var(--primary-accent);
color: #000;
font-weight: 600;
border-radius: 12px;
}
.btn-accent:hover {
background-color: #a370f7;
color: #000;
}
</style>
{% block extra_css %}{% endblock %}
</head> </head>
<body> <body>
{% block content %}{% endblock %} <div class="container py-3">
</body> {% block content %}{% endblock %}
</div>
</html> <nav class="bottom-nav">
<a href="{% url 'home' %}" class="nav-item {% if request.resolver_match.url_name == 'home' %}active{% endif %}">
<i class="fas fa-home"></i>
<span>Home</span>
</a>
<a href="{% url 'new_fit' %}" class="nav-item {% if request.resolver_match.url_name == 'new_fit' %}active{% endif %}">
<i class="fas fa-plus-circle"></i>
<span>New Fit</span>
</a>
<a href="{% url 'outfit_list' %}" class="nav-item {% if request.resolver_match.url_name == 'outfit_list' %}active{% endif %}">
<i class="fas fa-tshirt"></i>
<span>Outfits</span>
</a>
<a href="{% url 'wardrobe_list' %}" class="nav-item {% if request.resolver_match.url_name == 'wardrobe_list' %}active{% endif %}">
<i class="fas fa-box"></i>
<span>Wardrobe</span>
</a>
<a href="{% url 'accessory_list' %}" class="nav-item {% if request.resolver_match.url_name == 'accessory_list' %}active{% endif %}">
<i class="fas fa-gem"></i>
<span>Accs</span>
</a>
</nav>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
{% block extra_js %}{% endblock %}
</body>
</html>

View File

@ -0,0 +1,205 @@
{% extends 'base.html' %}
{% load static %}
{% block content %}
<div class="container-fluid py-2">
<!-- Header -->
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0 fw-bold">Accessories</h4>
<div class="d-flex gap-2">
<!-- View Toggle -->
<div class="btn-group btn-group-sm bg-secondary rounded-pill p-1 shadow-sm me-2">
<button type="button" class="btn btn-sm rounded-pill px-3 active" id="view-grid" title="Grid View">
<i class="fas fa-th-large"></i>
</button>
<button type="button" class="btn btn-sm rounded-pill px-3" id="view-list" title="List View">
<i class="fas fa-list"></i>
</button>
</div>
<a href="{% url 'add_accessory_item' %}" class="btn btn-accent btn-sm rounded-pill px-3 shadow-sm d-flex align-items-center">
<i class="fas fa-plus me-1"></i> <span class="d-none d-sm-inline">Add Accessory</span><span class="d-inline d-sm-none">Add</span>
</a>
</div>
</div>
<!-- Filter Toggle and Sort Bars -->
<div class="mb-3">
<button class="btn btn-outline-secondary btn-sm rounded-pill px-3 mb-2" type="button" data-bs-toggle="collapse" data-bs-target="#filterCollapse" aria-expanded="false">
<i class="fas fa-filter me-1"></i> Filter & Sort
</button>
<div class="collapse {% if sort != 'recent' or request.GET.q %}show{% endif %}" id="filterCollapse">
<div class="glass-card p-3 rounded-3 mb-3">
<form method="get" class="mb-3">
{% if current_main %}<input type="hidden" name="main_category" value="{{ current_main.id }}">{% endif %}
{% if current_sub %}<input type="hidden" name="subcategory" value="{{ current_sub.id }}">{% endif %}
<input type="hidden" name="sort" value="{{ sort }}">
<div class="input-group input-group-sm bg-secondary rounded-pill px-2">
<span class="input-group-text bg-transparent border-0 text-dim"><i class="fas fa-search"></i></span>
<input type="text" name="q" class="form-control bg-transparent border-0 text-white" placeholder="Search accessories..." value="{{ request.GET.q }}">
{% if request.GET.q %}
<a href="?sort={{ sort }}{% if current_main %}&main_category={{ current_main.id }}{% endif %}{% if current_sub %}&subcategory={{ current_sub.id }}{% endif %}" class="btn btn-link text-dim p-0 me-2 d-flex align-items-center">
<i class="fas fa-times-circle"></i>
</a>
{% endif %}
</div>
</form>
<div class="d-flex flex-wrap gap-2">
<a href="?sort=recent{% if current_main %}&main_category={{ current_main.id }}{% endif %}{% if current_sub %}&subcategory={{ current_sub.id }}{% endif %}{% if request.GET.q %}&q={{ request.GET.q }}{% endif %}"
class="btn {% if sort == 'recent' %}btn-accent{% else %}btn-outline-secondary{% endif %} btn-xs rounded-pill px-3">
Newest
</a>
<a href="?sort=alpha{% if current_main %}&main_category={{ current_main.id }}{% endif %}{% if current_sub %}&subcategory={{ current_sub.id }}{% endif %}{% if request.GET.q %}&q={{ request.GET.q }}{% endif %}"
class="btn {% if sort == 'alpha' %}btn-accent{% else %}btn-outline-secondary{% endif %} btn-xs rounded-pill px-3">
A-Z
</a>
</div>
</div>
</div>
</div>
<!-- Main Categories Tabs -->
<div class="d-flex gap-2 overflow-auto no-scrollbar pb-3 mb-2">
<a class="btn btn-xs rounded-pill px-3 {% if not current_main %}btn-accent{% else %}btn-outline-secondary{% endif %}" href="?sort={{ sort }}">All</a>
{% for main_cat in main_categories %}
<a class="btn btn-xs rounded-pill px-3 {% if current_main.id == main_cat.id %}btn-accent{% else %}btn-outline-secondary{% endif %}"
href="?main_category={{ main_cat.id }}&sort={{ sort }}">{{ main_cat.name }}</a>
{% endfor %}
</div>
<!-- Subcategories Tabs -->
{% if current_main and subcategories %}
<div class="d-flex gap-2 overflow-auto no-scrollbar pb-3 mb-3">
<a href="?main_category={{ current_main.id }}&sort={{ sort }}"
class="btn btn-xs {% if not current_sub %}btn-secondary{% else %}btn-outline-secondary{% endif %} rounded-pill px-3">
All {{ current_main.name }}
</a>
{% for sub in subcategories %}
<a href="?main_category={{ current_main.id }}&subcategory={{ sub.id }}&sort={{ sort }}"
class="btn btn-xs {% if current_sub.id == sub.id %}btn-secondary{% else %}btn-outline-secondary{% endif %} rounded-pill px-3">
{{ sub.name }}
</a>
{% endfor %}
</div>
{% endif %}
<!-- Items Grid/List Container -->
<div class="row g-2" id="items-container">
{% for item in items %}
<div class="item-wrapper col-4 col-sm-3 col-md-2">
<div class="item-card position-relative rounded-1 d-flex flex-column align-items-center p-1" style="cursor: pointer; transition: all 0.1s;">
<a href="{% url 'accessory_item_detail' item.pk %}" class="stretched-link"></a>
<!-- Grid Content (Default) -->
<div class="item-img-container position-relative rounded-1 overflow-hidden bg-secondary w-100 shadow-sm" style="aspect-ratio: 1/1;">
{% if item.image %}
<img src="{{ item.image.url }}" class="w-100 h-100 object-fit-contain">
{% else %}
<div class="w-100 h-100 d-flex align-items-center justify-content-center">
<i class="fas fa-gem text-muted"></i>
</div>
{% endif %}
</div>
<div class="item-name-label mt-1 text-center small text-truncate w-100 px-1" style="font-size: 0.65rem; color: var(--text-main);">
{{ item.name|default:"Unnamed" }}
</div>
<!-- List Content (Hidden) -->
<div class="item-list-content d-none w-100 align-items-center gap-2 px-1 py-1">
<div class="list-img-box rounded-1 bg-secondary overflow-hidden" style="width: 28px; height: 28px; flex-shrink: 0;">
{% if item.image %}
<img src="{{ item.image.url }}" class="w-100 h-100 object-fit-cover">
{% else %}
<div class="w-100 h-100 d-flex align-items-center justify-content-center">
<i class="fas fa-gem text-muted" style="font-size: 0.5rem;"></i>
</div>
{% endif %}
</div>
<div class="flex-grow-1 text-truncate">
<div class="small fw-bold text-truncate" style="font-size: 0.75rem;">{{ item.name|default:"Unnamed" }}</div>
<div class="text-dim text-truncate" style="font-size: 0.6rem;">{{ item.category.name|default:"Uncategorized" }}</div>
</div>
</div>
</div>
</div>
{% empty %}
<div class="col-12 text-center py-5">
<i class="fas fa-gem text-dim mb-2" style="font-size: 3rem;"></i>
<p class="text-dim">No accessories found.</p>
</div>
{% endfor %}
</div>
</div>
{% endblock %}
{% block extra_css %}
<style>
.item-card:hover {
background-color: rgba(255, 255, 255, 0.08);
transform: translateY(-2px);
}
.list-mode .item-card {
flex-direction: row !important;
align-items: center !important;
padding: 4px 8px !important;
}
.list-mode .item-img-container,
.list-mode .item-name-label {
display: none !important;
}
.list-mode .item-list-content {
display: flex !important;
}
.no-scrollbar::-webkit-scrollbar { display: none; }
.object-fit-contain { object-fit: contain; }
.object-fit-cover { object-fit: cover; }
.btn-xs {
padding: 0.25rem 0.5rem;
font-size: 0.7rem;
}
</style>
{% endblock %}
{% block extra_js %}
<script>
document.addEventListener('DOMContentLoaded', function() {
const viewGrid = document.getElementById('view-grid');
const viewList = document.getElementById('view-list');
const itemsContainer = document.getElementById('items-container');
const wrappers = document.querySelectorAll('.item-wrapper');
if (viewGrid && viewList) {
viewGrid.addEventListener('click', () => {
viewGrid.classList.add('active');
viewList.classList.remove('active');
itemsContainer.classList.remove('list-mode');
wrappers.forEach(w => {
w.className = 'item-wrapper col-4 col-sm-3 col-md-2';
});
localStorage.setItem('accessories_view', 'grid');
});
viewList.addEventListener('click', () => {
viewList.classList.add('active');
viewGrid.classList.remove('active');
itemsContainer.classList.add('list-mode');
wrappers.forEach(w => {
w.className = 'item-wrapper col-12 col-sm-6 col-md-4';
});
localStorage.setItem('accessories_view', 'list');
});
// Restore preference
const savedView = localStorage.getItem('accessories_view');
if (savedView === 'list') {
viewList.click();
}
}
});
</script>
{% endblock %}

View File

@ -1,145 +1,318 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}{{ project_name }}{% endblock %} {% block content %}
<!-- Header -->
<div class="d-flex justify-content-between align-items-center mb-4">
<div class="season-filter d-flex gap-3">
<a href="?season=summer" class="text-decoration-none">
<i class="fas fa-sun season-icon {% if season_filter == 'summer' %}active text-warning{% endif %}" title="Summer"></i>
</a>
<a href="?season=spring" class="text-decoration-none">
<i class="fas fa-seedling season-icon {% if season_filter == 'spring' %}active text-success{% endif %}" title="Spring"></i>
</a>
<a href="?season=autumn" class="text-decoration-none d-flex align-items-center">
<i class="fas fa-tree season-icon {% if season_filter == 'autumn' %}active{% endif %}" title="Autumn" style="{% if season_filter == 'autumn' %}color: #8B4513 !important; opacity: 1;{% endif %}"></i>
</a>
<a href="?season=winter" class="text-decoration-none">
<i class="fas fa-snowflake season-icon {% if season_filter == 'winter' %}active text-info{% endif %}" title="Winter"></i>
</a>
</div>
<div class="text-center">
<div class="small text-uppercase tracking-wider text-dim" style="letter-spacing: 1px;">Total Items</div>
<h3 class="mb-0 fw-bold accent-lavender">{{ total_items }}</h3>
</div>
<div class="avatar-selector cursor-pointer" data-bs-toggle="modal" data-bs-target="#avatarModal">
{% if profile.avatar %}
<img src="{{ profile.avatar.url }}" class="rounded-circle border border-2 border-secondary shadow-sm" style="width: 48px; height: 48px; object-fit: cover;">
{% else %}
<div class="rounded-circle d-flex align-items-center justify-content-center border border-2 border-secondary shadow-sm" style="width: 48px; height: 48px; background-color: #333; color: #999;">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path d="M12 12C14.21 12 16 10.21 16 8C16 5.79 14.21 4 12 4C9.79 4 8 5.79 8 8C8 10.21 9.79 12 12 12ZM12 14C9.33 14 4 15.34 4 18V20H20V18C20 15.34 14.67 14 12 14Z"/>
</svg>
</div>
{% endif %}
</div>
</div>
{% block head %} <!-- Weekly Calendar -->
<link rel="preconnect" href="https://fonts.googleapis.com"> <div class="calendar-grid">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <div class="d-flex justify-content-between align-items-center mb-3">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" rel="stylesheet"> <h6 class="mb-0 text-dim fw-bold"><i class="far fa-calendar-alt me-2"></i>Weekly Planner</h6>
<a href="{% url 'new_fit' %}" class="btn btn-sm btn-outline-secondary py-0" style="font-size: 0.7rem;">+ Plan</a>
</div>
<!-- Row 1: Mon, Tue, Wed -->
<div class="row g-2 mb-2">
{% for day in calendar_data|slice:":3" %}
<div class="col-4">
<div class="glass-card p-2 text-center h-100 d-flex flex-column justify-content-between">
<div>
<div class="small text-dim" style="font-size: 0.65rem;">{{ day.day }}</div>
<div class="fw-bold {% if day.date == today %}accent-lavender{% endif %}" style="font-size: 0.9rem;">{{ day.date|date:"d" }}</div>
</div>
<div class="outfit-preview my-1 position-relative">
{% if day.outfit %}
<form action="{% url 'remove_assignment' day.date|date:'Y-m-d' %}" method="post" class="position-absolute top-0 end-0 z-1">
{% csrf_token %}
<button type="submit" class="btn btn-link text-danger p-0" style="line-height: 1; margin-top: -8px; margin-right: -8px;">
<i class="fas fa-times-circle" style="font-size: 0.9rem; background: rgba(0,0,0,0.6); border-radius: 50%;"></i>
</button>
</form>
<div class="rounded bg-secondary position-relative overflow-hidden d-flex align-items-center justify-content-center p-1" style="height: 100px;">
<div class="d-flex align-items-center justify-content-center h-100 w-100 overflow-hidden" style="gap: 3px;">
{% for item in day.outfit.items.all %}
<img src="{{ item.image.url }}" class="outfit-item-square shadow-sm" alt="{{ item.name }}">
{% endfor %}
{% for acc in day.outfit.accessories.all %}
<img src="{{ acc.image.url }}" class="outfit-item-square shadow-sm" alt="{{ acc.name }}">
{% endfor %}
</div>
{% if not day.outfit.items.exists and not day.outfit.accessories.exists %}
<div class="w-100 h-100 d-flex align-items-center justify-content-center bg-dark">
<i class="fas fa-tshirt text-dim"></i>
</div>
{% endif %}
</div>
<div class="small mt-1 text-truncate fw-bold" style="font-size: 0.7rem;">{{ day.outfit.name|default:"Outfit" }}</div>
{% else %}
<div class="rounded border border-dashed border-secondary d-flex align-items-center justify-content-center cursor-pointer"
style="height: 100px; border-style: dashed !important; background: rgba(255,255,255,0.02);"
data-bs-toggle="modal" data-bs-target="#assignModal" data-date="{{ day.date|date:'Y-m-d' }}" data-day="{{ day.day }}">
<i class="fas fa-plus text-dim" style="font-size: 1rem;"></i>
</div>
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
<!-- Row 2: Thu, Fri -->
<div class="row g-2 mb-2">
{% for day in calendar_data|slice:"3:5" %}
<div class="col-6">
<div class="glass-card p-2 text-center h-100 d-flex flex-column justify-content-between">
<div>
<div class="small text-dim" style="font-size: 0.65rem;">{{ day.day }}</div>
<div class="fw-bold {% if day.date == today %}accent-lavender{% endif %}" style="font-size: 0.9rem;">{{ day.date|date:"d" }}</div>
</div>
<div class="outfit-preview my-1 position-relative">
{% if day.outfit %}
<form action="{% url 'remove_assignment' day.date|date:'Y-m-d' %}" method="post" class="position-absolute top-0 end-0 z-1">
{% csrf_token %}
<button type="submit" class="btn btn-link text-danger p-0" style="line-height: 1; margin-top: -8px; margin-right: -8px;">
<i class="fas fa-times-circle" style="font-size: 0.9rem; background: rgba(0,0,0,0.6); border-radius: 50%;"></i>
</button>
</form>
<div class="rounded bg-secondary position-relative overflow-hidden d-flex align-items-center justify-content-center p-1" style="height: 100px;">
<div class="d-flex align-items-center justify-content-center h-100 w-100 overflow-hidden" style="gap: 4px;">
{% for item in day.outfit.items.all %}
<img src="{{ item.image.url }}" class="outfit-item-square shadow-sm" alt="{{ item.name }}">
{% endfor %}
{% for acc in day.outfit.accessories.all %}
<img src="{{ acc.image.url }}" class="outfit-item-square shadow-sm" alt="{{ acc.name }}">
{% endfor %}
</div>
{% if not day.outfit.items.exists and not day.outfit.accessories.exists %}
<div class="w-100 h-100 d-flex align-items-center justify-content-center bg-dark">
<i class="fas fa-tshirt text-dim"></i>
</div>
{% endif %}
</div>
<div class="small mt-1 text-truncate fw-bold" style="font-size: 0.7rem;">{{ day.outfit.name|default:"Outfit" }}</div>
{% else %}
<div class="rounded border border-dashed border-secondary d-flex align-items-center justify-content-center cursor-pointer"
style="height: 100px; border-style: dashed !important; background: rgba(255,255,255,0.02);"
data-bs-toggle="modal" data-bs-target="#assignModal" data-date="{{ day.date|date:'Y-m-d' }}" data-day="{{ day.day }}">
<i class="fas fa-plus text-dim" style="font-size: 1rem;"></i>
</div>
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
<!-- Row 3: Sat, Sun -->
<div class="row g-2">
{% for day in calendar_data|slice:"5:" %}
<div class="col-6">
<div class="glass-card p-2 text-center h-100 d-flex flex-column justify-content-between">
<div>
<div class="small text-dim" style="font-size: 0.65rem;">{{ day.day }}</div>
<div class="fw-bold {% if day.date == today %}accent-lavender{% endif %}" style="font-size: 0.9rem;">{{ day.date|date:"d" }}</div>
</div>
<div class="outfit-preview my-1 position-relative">
{% if day.outfit %}
<form action="{% url 'remove_assignment' day.date|date:'Y-m-d' %}" method="post" class="position-absolute top-0 end-0 z-1">
{% csrf_token %}
<button type="submit" class="btn btn-link text-danger p-0" style="line-height: 1; margin-top: -8px; margin-right: -8px;">
<i class="fas fa-times-circle" style="font-size: 0.9rem; background: rgba(0,0,0,0.6); border-radius: 50%;"></i>
</button>
</form>
<div class="rounded bg-secondary position-relative overflow-hidden d-flex align-items-center justify-content-center p-1" style="height: 100px;">
<div class="d-flex align-items-center justify-content-center h-100 w-100 overflow-hidden" style="gap: 4px;">
{% for item in day.outfit.items.all %}
<img src="{{ item.image.url }}" class="outfit-item-square shadow-sm" alt="{{ item.name }}">
{% endfor %}
{% for acc in day.outfit.accessories.all %}
<img src="{{ acc.image.url }}" class="outfit-item-square shadow-sm" alt="{{ acc.name }}">
{% endfor %}
</div>
{% if not day.outfit.items.exists and not day.outfit.accessories.exists %}
<div class="w-100 h-100 d-flex align-items-center justify-content-center bg-dark">
<i class="fas fa-tshirt text-dim"></i>
</div>
{% endif %}
</div>
<div class="small mt-1 text-truncate fw-bold" style="font-size: 0.7rem;">{{ day.outfit.name|default:"Outfit" }}</div>
{% else %}
<div class="rounded border border-dashed border-secondary d-flex align-items-center justify-content-center cursor-pointer"
style="height: 100px; border-style: dashed !important; background: rgba(255,255,255,0.02);"
data-bs-toggle="modal" data-bs-target="#assignModal" data-date="{{ day.date|date:'Y-m-d' }}" data-day="{{ day.day }}">
<i class="fas fa-plus text-dim" style="font-size: 1rem;"></i>
</div>
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
</div>
<!-- Assign Outfit Modal -->
<div class="modal fade" id="assignModal" tabindex="-1" aria-labelledby="assignModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-fullscreen-sm-down">
<div class="modal-content bg-dark text-white border-secondary">
<div class="modal-header border-secondary">
<h5 class="modal-title" id="assignModalLabel">Select Outfit for <span id="modalDateDisplay"></span></h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body p-0" style="max-height: 70vh; overflow-y: auto;">
<div class="list-group list-group-flush">
{% for outfit in all_outfits %}
<form action="{% url 'assign_outfit' %}" method="post">
{% csrf_token %}
<input type="hidden" name="outfit_id" value="{{ outfit.id }}">
<input type="hidden" name="date" class="modal-date-input">
<button type="submit" class="list-group-item list-group-item-action bg-dark text-white border-secondary d-flex align-items-center gap-3 py-3">
<div class="rounded bg-secondary overflow-hidden d-flex align-items-center justify-content-center p-1" style="width: 80px; height: 50px; flex-shrink: 0;">
<div class="d-flex align-items-center justify-content-center h-100 w-100 overflow-hidden" style="gap: 2px;">
{% for item in outfit.items.all|slice:":3" %}
<img src="{{ item.image.url }}" class="outfit-item-square" style="height: 70% !important;" alt="">
{% empty %}
<div class="w-100 h-100 d-flex align-items-center justify-content-center bg-dark">
<i class="fas fa-tshirt text-dim"></i>
</div>
{% endfor %}
</div>
</div>
<div class="flex-grow-1">
<div class="fw-bold">{{ outfit.name|default:"Unnamed Outfit" }}</div>
<div class="small text-dim">{{ outfit.items.count }} items, {{ outfit.accessories.count }} accessories</div>
</div>
<i class="fas fa-chevron-right text-dim"></i>
</button>
</form>
{% empty %}
<div class="p-4 text-center text-dim">
<i class="fas fa-info-circle mb-2 d-block" style="font-size: 2rem;"></i>
<p>No outfits created yet.</p>
<a href="{% url 'new_fit' %}" class="btn btn-sm btn-outline-lavender">Create your first fit</a>
</div>
{% endfor %}
</div>
</div>
<div class="modal-footer border-secondary">
<a href="#" id="createNewFitLink" class="btn btn-outline-lavender w-100">Create New Fit for this day</a>
</div>
</div>
</div>
</div>
<!-- Avatar Update Modal -->
<div class="modal fade" id="avatarModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content bg-dark text-white border-secondary">
<div class="modal-header border-secondary">
<h5 class="modal-title">Change Profile Picture</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form action="{% url 'update_avatar' %}" method="post" enctype="multipart/form-data">
{% csrf_token %}
<div class="modal-body">
<div class="mb-3 text-center">
{% if profile.avatar %}
<img src="{{ profile.avatar.url }}" class="rounded-circle border border-2 border-secondary mb-3" style="width: 100px; height: 100px; object-fit: cover;">
{% endif %}
<div class="text-start">
<label class="form-label small text-uppercase fw-bold text-dim">Select Image</label>
{{ avatar_form.avatar }}
</div>
</div>
</div>
<div class="modal-footer border-secondary">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-accent px-4">Save Changes</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
{% block extra_css %}
<style> <style>
:root { .text-dim { color: var(--text-dim); }
--bg-color-start: #6a11cb; .tracking-wider { letter-spacing: 0.05em; }
--bg-color-end: #2575fc; .cursor-pointer { cursor: pointer; }
--text-color: #ffffff; .btn-outline-lavender {
--card-bg-color: rgba(255, 255, 255, 0.01); color: var(--primary-accent);
--card-border-color: rgba(255, 255, 255, 0.1); border-color: var(--primary-accent);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: 'Inter', sans-serif;
background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end));
color: var(--text-color);
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
text-align: center;
overflow: hidden;
position: relative;
}
body::before {
content: '';
position: absolute;
inset: 0;
background-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='100' height='100' viewBox='0 0 100 100'><path d='M-10 10L110 10M10 -10L10 110' stroke-width='1' stroke='rgba(255,255,255,0.05)'/></svg>");
animation: bg-pan 20s linear infinite;
z-index: -1;
}
@keyframes bg-pan {
0% {
background-position: 0% 0%;
} }
.btn-outline-lavender:hover {
100% { background-color: var(--primary-accent);
background-position: 100% 100%; color: #000;
} }
} .season-icon { font-size: 1.5rem; transition: all 0.2s; opacity: 0.5; }
.season-icon.active { opacity: 1; transform: scale(1.2); }
main { .outfit-item-square {
padding: 2rem; height: 75%;
} max-width: 30%;
aspect-ratio: 1/1;
.card { object-fit: cover;
background: var(--card-bg-color); border-radius: 4px;
border: 1px solid var(--card-border-color); transition: transform 0.2s;
border-radius: 16px; border: 1px solid rgba(255,255,255,0.1);
padding: 2.5rem 2rem; }
backdrop-filter: blur(20px); .outfit-item-square:hover {
-webkit-backdrop-filter: blur(20px); transform: scale(1.05);
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.25); z-index: 5;
}
h1 {
font-size: clamp(2.2rem, 3vw + 1.2rem, 3.2rem);
font-weight: 700;
margin: 0 0 1.2rem;
letter-spacing: -0.02em;
}
p {
margin: 0.5rem 0;
font-size: 1.1rem;
opacity: 0.92;
}
.loader {
margin: 1.5rem auto;
width: 56px;
height: 56px;
border: 4px solid rgba(255, 255, 255, 0.25);
border-top-color: #fff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
} }
}
.runtime code {
background: rgba(0, 0, 0, 0.25);
padding: 0.15rem 0.45rem;
border-radius: 4px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
footer {
position: absolute;
bottom: 1rem;
width: 100%;
text-align: center;
font-size: 0.85rem;
opacity: 0.75;
}
</style> </style>
{% endblock %} {% endblock %}
{% block content %} {% block extra_js %}
<main> <script>
<div class="card"> document.addEventListener('DOMContentLoaded', function() {
<h1>Analyzing your requirements and generating your app…</h1> var assignModal = document.getElementById('assignModal');
<div class="loader" role="status" aria-live="polite" aria-label="Applying initial changes"> assignModal.addEventListener('show.bs.modal', function (event) {
<span class="sr-only">Loading…</span> var button = event.relatedTarget;
</div> var date = button.getAttribute('data-date');
<p class="hint">AppWizzy AI is collecting your requirements and applying the first changes.</p> var day = button.getAttribute('data-day');
<p class="hint">This page will refresh automatically as the plan is implemented.</p>
<p class="runtime"> document.getElementById('modalDateDisplay').textContent = day + ' (' + date + ')';
Runtime: Django <code>{{ django_version }}</code> · Python <code>{{ python_version }}</code>
— UTC <code>{{ current_time|date:"Y-m-d H:i:s" }}</code> var dateInputs = assignModal.querySelectorAll('.modal-date-input');
</p> dateInputs.forEach(function(input) {
</div> input.value = date;
</main> });
<footer>
Page updated: {{ current_time|date:"Y-m-d H:i:s" }} (UTC) var createLink = document.getElementById('createNewFitLink');
</footer> createLink.href = "{% url 'new_fit' %}?date=" + date;
});
});
</script>
{% endblock %} {% endblock %}

View File

@ -0,0 +1,51 @@
{% extends 'base.html' %}
{% load static %}
{% block content %}
<div class="container py-5">
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card bg-dark text-white border-secondary shadow-lg">
<div class="card-header border-secondary d-flex justify-content-between align-items-center">
<h3 class="mb-0">{{ item.name|default:"Unnamed Item" }}</h3>
<a href="{% if type == 'wardrobe' %}{% url 'wardrobe_list' %}{% else %}{% url 'accessory_list' %}{% endif %}" class="btn btn-outline-light btn-sm">
<i class="bi bi-arrow-left"></i> Back
</a>
</div>
<div class="card-body text-center p-0">
{% if item.image %}
<img src="{{ item.image.url }}" class="img-fluid w-100" style="max-height: 600px; object-fit: contain;" alt="{{ item.name }}">
{% else %}
<div class="py-5 bg-secondary text-dark">
<i class="bi bi-image" style="font-size: 5rem;"></i>
<p>No image available</p>
</div>
{% endif %}
</div>
<div class="card-footer border-secondary">
<div class="row">
<div class="col-sm-6">
<p class="mb-1 text-muted small">CATEGORY</p>
<p class="mb-3">{{ item.category|default:"Uncategorized" }}</p>
<p class="mb-1 text-muted small">SEASON</p>
<p class="mb-3"><span class="badge bg-primary">{{ item.get_season_display|default:"All Seasons" }}</span></p>
</div>
<div class="col-sm-6">
<p class="mb-1 text-muted small">DATE ADDED</p>
<p class="mb-3">{{ item.date_added|date:"F j, Y, g:i a" }}</p>
<form action="{% if type == 'wardrobe' %}{% url 'delete_wardrobe_item' item.pk %}{% else %}{% url 'delete_accessory_item' item.pk %}{% endif %}" method="POST" onsubmit="return confirm('Are you sure you want to delete this item?');">
{% csrf_token %}
<button type="submit" class="btn btn-danger w-100">
<i class="bi bi-trash"></i> Delete Item
</button>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,92 @@
{% extends 'base.html' %}
{% load static %}
{% block content %}
<div class="container py-5">
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card bg-dark text-white border-secondary shadow">
<div class="card-header border-secondary bg-black bg-opacity-50">
<h3 class="mb-0 text-center">{{ title }}</h3>
</div>
<div class="card-body">
<form method="post" enctype="multipart/form-data" id="itemForm">
{% csrf_token %}
<div class="mb-3">
<label class="form-label text-muted small fw-bold">NAME</label>
{{ form.name }}
{% if form.name.errors %}
<div class="text-danger small">{{ form.name.errors }}</div>
{% endif %}
</div>
<div class="row mb-3">
<div class="col-md-6">
<label class="form-label text-muted small fw-bold">MAIN CATEGORY</label>
{{ form.main_category }}
{% if form.main_category.errors %}
<div class="text-danger small">{{ form.main_category.errors }}</div>
{% endif %}
<div class="mt-2">
{{ form.new_main_category }}
</div>
</div>
<div class="col-md-6">
<label class="form-label text-muted small fw-bold">SUBCATEGORY</label>
{{ form.category }}
{% if form.category.errors %}
<div class="text-danger small">{{ form.category.errors }}</div>
{% endif %}
<div class="mt-2">
{{ form.new_subcategory }}
</div>
</div>
</div>
<div class="mb-3">
<label class="form-label text-muted small fw-bold">SEASON</label>
{{ form.season }}
</div>
<div class="mb-4">
<label class="form-label text-muted small fw-bold">IMAGE</label>
{{ form.image }}
</div>
<div class="d-grid gap-2">
<button type="submit" class="btn btn-primary btn-lg">
<i class="bi bi-check2-circle"></i> Save Item
</button>
<a href="{% if type == 'wardrobe' %}{% url 'wardrobe_list' %}{% else %}{% url 'accessory_list' %}{% endif %}" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
<script>
document.getElementById('id_main_category').addEventListener('change', function() {
var parentId = this.value;
var subcategorySelect = document.getElementById('id_subcategory');
// Clear existing options
subcategorySelect.innerHTML = '<option value="">Select Subcategory</option>';
if (parentId) {
fetch(`/ajax/get-subcategories/?parent_id=${parentId}`)
.then(response => response.json())
.then(data => {
data.forEach(function(sub) {
var option = document.createElement('option');
option.value = sub.id;
option.text = sub.name;
subcategorySelect.appendChild(option);
});
});
}
});
</script>
{% endblock %}

View File

@ -0,0 +1,383 @@
{% extends "base.html" %}
{% block content %}
<div class="container-fluid py-3">
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0 fw-bold">{{ title }}</h4>
<a href="{% if date_str %}{% url 'home' %}{% else %}{% url 'outfit_list' %}{% endif %}" class="btn-close btn-close-white" aria-label="Close"></a>
</div>
<form method="post" id="outfit-form" enctype="multipart/form-data">
{% csrf_token %}
{% if form.errors %}
<div class="alert alert-danger small py-2">
<ul class="mb-0">
{% for field, errors in form.errors.items %}
{% for error in errors %}
<li>{{ field }}: {{ error }}</li>
{% endfor %}
{% endfor %}
</ul>
</div>
{% endif %}
<div class="row g-3 mb-4">
<div class="col-6">
<label class="form-label text-dim small text-uppercase fw-bold">Outfit Name</label>
{{ form.name }}
</div>
<div class="col-6">
<label class="form-label text-dim small text-uppercase fw-bold">Season</label>
{{ form.season }}
</div>
</div>
<!-- View Toggle & Category Header -->
<div class="d-flex justify-content-between align-items-end mb-2">
<div>
<label class="form-label text-dim small text-uppercase fw-bold mb-0">Select Items</label>
<div class="small text-accent" id="item-count">0 selected</div>
</div>
<div class="btn-group btn-group-sm bg-secondary rounded-pill p-1 shadow-sm">
<button type="button" class="btn btn-sm rounded-pill px-3 active" id="view-grid-img" title="Grid View (Icons)">
<i class="fas fa-th-large"></i>
</button>
<button type="button" class="btn btn-sm rounded-pill px-3" id="view-list-mode" title="List View (Details)">
<i class="fas fa-list"></i>
</button>
</div>
</div>
<!-- Category Navigation -->
<div class="category-nav mb-3">
<div class="d-flex gap-2 overflow-x-auto no-scrollbar pb-2" id="main-cat-list">
<button type="button" class="btn btn-sm btn-outline-secondary rounded-pill px-3 active-cat" data-cat-id="all">All</button>
{% for cat in main_categories %}
<button type="button" class="btn btn-sm btn-outline-secondary rounded-pill px-3 main-cat-btn" data-cat-id="{{ cat.id }}">
{{ cat.name }}
</button>
{% endfor %}
</div>
<div class="d-flex gap-2 overflow-x-auto no-scrollbar pb-2 mt-1 d-none" id="sub-cat-list">
<!-- Subcategories will be injected here -->
</div>
</div>
<!-- Wardrobe Item Grid -->
<div class="item-grid-container glass-card p-3 mb-4" style="height: 380px; overflow-y: auto;">
<div class="row g-2" id="wardrobe-items-grid">
{% for item in wardrobe_items %}
<div class="item-wrapper col-4 col-sm-3 col-md-2"
data-cat-id="{% if item.category %}{{ item.category.id }}{% else %}none{% endif %}"
data-parent-cat-id="{% if item.category %}{% if item.category.parent %}{{ item.category.parent.id }}{% else %}{{ item.category.id }}{% endif %}{% else %}none{% endif %}">
<div class="item-card position-relative rounded-1 d-flex flex-column align-items-center p-1"
data-id="{{ item.id }}" data-real-id="{{ item.id }}" data-type="item" style="cursor: pointer; transition: all 0.1s;">
<!-- Grid Content -->
<div class="item-img-container position-relative rounded-1 overflow-hidden bg-secondary w-100 shadow-sm" style="aspect-ratio: 1/1;">
<img src="{{ item.image.url }}" class="w-100 h-100 object-fit-contain item-img">
<div class="check-overlay position-absolute top-0 end-0 p-1 d-none" style="z-index: 5;">
<i class="fas fa-check-circle text-primary-accent bg-white rounded-circle shadow-sm"></i>
</div>
</div>
<div class="item-name-label mt-1 text-center small text-truncate w-100 px-1" style="font-size: 0.65rem; color: var(--text-main);">
{{ item.name|default:"Item" }}
</div>
<!-- List Content (Hidden by default) -->
<div class="item-list-content d-none w-100 align-items-center gap-2 px-1 py-1 position-relative">
<div class="list-img-box rounded-1 bg-secondary overflow-hidden" style="width: 28px; height: 28px; flex-shrink: 0;">
<img src="{{ item.image.url }}" class="w-100 h-100 object-fit-cover">
</div>
<span class="small text-truncate flex-grow-1" style="font-size: 0.75rem;">{{ item.name|default:"Item" }}</span>
<div class="list-check-overlay d-none">
<i class="fas fa-check-circle text-primary-accent" style="font-size: 0.8rem;"></i>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
<div class="mb-4">
<label class="form-label text-dim small text-uppercase fw-bold mb-2">Select Accessories</label>
<div class="item-grid-container glass-card p-3" style="max-height: 250px; overflow-y: auto;">
<div class="row g-2" id="accessories-grid">
{% for acc in accessories %}
<div class="item-wrapper col-4 col-sm-3 col-md-2">
<div class="item-card position-relative rounded-1 d-flex flex-column align-items-center p-1"
data-id="{{ acc.id }}" data-real-id="{{ acc.id }}" data-type="accessory" style="cursor: pointer; transition: all 0.1s;">
<!-- Grid Content -->
<div class="item-img-container position-relative rounded-1 overflow-hidden bg-secondary w-100 shadow-sm" style="aspect-ratio: 1/1;">
<img src="{{ acc.image.url }}" class="w-100 h-100 object-fit-contain item-img">
<div class="check-overlay position-absolute top-0 end-0 p-1 d-none" style="z-index: 5;">
<i class="fas fa-check-circle text-primary-accent bg-white rounded-circle shadow-sm"></i>
</div>
</div>
<div class="item-name-label mt-1 text-center small text-truncate w-100 px-1" style="font-size: 0.65rem; color: var(--text-main);">
{{ acc.name|default:"Acc" }}
</div>
<!-- List Content (Hidden by default) -->
<div class="item-list-content d-none w-100 align-items-center gap-2 px-1 py-1 position-relative">
<div class="list-img-box rounded-1 bg-secondary overflow-hidden" style="width: 28px; height: 28px; flex-shrink: 0;">
<img src="{{ acc.image.url }}" class="w-100 h-100 object-fit-cover">
</div>
<span class="small text-truncate flex-grow-1" style="font-size: 0.75rem;">{{ acc.name|default:"Acc" }}</span>
<div class="list-check-overlay d-none">
<i class="fas fa-check-circle text-primary-accent" style="font-size: 0.8rem;"></i>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
<div class="mb-4">
<label class="form-label text-dim small text-uppercase fw-bold">Folder / Group (Optional)</label>
{{ form.folder }}
<div class="mt-2">
<a class="text-accent small text-decoration-none" data-bs-toggle="collapse" href="#newFolderCollapse">
<i class="fas fa-plus-circle me-1"></i> Add New Group
</a>
<div class="collapse mt-2" id="newFolderCollapse">
{{ form.new_folder }}
</div>
</div>
</div>
<!-- Hidden Selects -->
<div class="d-none">
{{ form.items }}
{{ form.accessories }}
</div>
<button type="submit" class="btn btn-accent w-100 py-3 fw-bold rounded-3 shadow">SAVE OUTFIT</button>
</form>
</div>
{% endblock %}
{% block extra_css %}
<style>
/* Windows Folder Grid & List Styling */
.item-card:hover {
background-color: rgba(255, 255, 255, 0.08);
}
.item-card.selected {
background-color: rgba(var(--primary-accent-rgb, 187, 134, 252), 0.2) !important;
outline: 1px solid var(--primary-accent);
}
/* Grid View Styles */
.item-card.selected .check-overlay {
display: block !important;
}
/* List View Styles */
.list-mode .item-card {
flex-direction: row !important;
align-items: center !important;
padding: 4px 8px !important;
}
.list-mode .item-img-container,
.list-mode .item-name-label {
display: none !important;
}
.list-mode .item-list-content {
display: flex !important;
}
.list-mode .item-card.selected .list-check-overlay {
display: block !important;
}
.border-transparent { border-color: transparent; }
.text-primary-accent { color: var(--primary-accent); }
.no-scrollbar::-webkit-scrollbar { display: none; }
.active-cat {
background-color: var(--primary-accent) !important;
color: #000 !important;
border-color: var(--primary-accent) !important;
}
.object-fit-contain { object-fit: contain; }
.object-fit-cover { object-fit: cover; }
.item-grid-container::-webkit-scrollbar {
width: 6px;
}
.item-grid-container::-webkit-scrollbar-track {
background: rgba(255,255,255,0.05);
border-radius: 10px;
}
.item-grid-container::-webkit-scrollbar-thumb {
background: rgba(255,255,255,0.1);
border-radius: 10px;
}
</style>
{% endblock %}
{% block extra_js %}
<script>
document.addEventListener('DOMContentLoaded', function() {
const itemCards = document.querySelectorAll('.item-card');
const itemsSelect = document.querySelector('select[name="items"]');
const accsSelect = document.querySelector('select[name="accessories"]');
const itemCountBadge = document.getElementById('item-count');
const mainCatBtns = document.querySelectorAll('.main-cat-btn');
const allCatBtn = document.querySelector('[data-cat-id="all"]');
const subCatList = document.getElementById('sub-cat-list');
const itemWrappers = document.querySelectorAll('.item-wrapper');
const viewGridImg = document.getElementById('view-grid-img');
const viewListMode = document.getElementById('view-list-mode');
const wardrobeGrid = document.getElementById('wardrobe-items-grid');
const accessoriesGrid = document.getElementById('accessories-grid');
function updateBadge() {
const selectedCount = document.querySelectorAll('.item-card.selected').length;
itemCountBadge.textContent = `${selectedCount} items selected`;
}
// Item Selection Logic
itemCards.forEach(card => {
card.addEventListener('click', function() {
const id = this.dataset.realId || this.dataset.id;
const type = this.dataset.type;
const isSelected = this.classList.toggle('selected');
const select = (type === 'item') ? itemsSelect : accsSelect;
if (select) {
for (let option of select.options) {
if (option.value === id) {
option.selected = isSelected;
break;
}
}
}
updateBadge();
});
});
// View Toggle Logic
if (viewGridImg && viewListMode) {
viewGridImg.addEventListener('click', () => {
viewGridImg.classList.add('active');
viewListMode.classList.remove('active');
wardrobeGrid.classList.remove('list-mode');
accessoriesGrid.classList.remove('list-mode');
document.querySelectorAll('.item-wrapper').forEach(w => {
w.className = 'item-wrapper col-4 col-sm-3 col-md-2';
});
});
viewListMode.addEventListener('click', () => {
viewListMode.classList.add('active');
viewGridImg.classList.remove('active');
wardrobeGrid.classList.add('list-mode');
accessoriesGrid.classList.add('list-mode');
// In List mode, we want wider columns
document.querySelectorAll('.item-wrapper').forEach(w => {
w.className = 'item-wrapper col-12 col-sm-6 col-md-4';
});
});
}
// Category Filtering Logic
function filterItems(mainCatId, subCatId = null) {
itemWrappers.forEach(wrapper => {
// Only filter wardrobe items, not accessories which might not have categories in this context
if (!wrapper.dataset.parentCatId) return;
const itemMainCatId = wrapper.dataset.parentCatId;
const itemSubCatId = wrapper.dataset.catId;
if (mainCatId === 'all') {
wrapper.classList.remove('d-none');
} else {
if (subCatId) {
wrapper.classList.toggle('d-none', itemSubCatId !== subCatId);
} else {
wrapper.classList.toggle('d-none', itemMainCatId !== mainCatId);
}
}
});
}
if (allCatBtn) {
allCatBtn.addEventListener('click', function() {
document.querySelectorAll('.category-nav button').forEach(b => b.classList.remove('active-cat'));
this.classList.add('active-cat');
if (subCatList) subCatList.classList.add('d-none');
filterItems('all');
});
}
mainCatBtns.forEach(btn => {
btn.addEventListener('click', async function() {
document.querySelectorAll('.category-nav button').forEach(b => b.classList.remove('active-cat'));
this.classList.add('active-cat');
const catId = this.dataset.catId;
// Fetch subcategories
try {
const response = await fetch(`/ajax/get-subcategories/?parent_id=${catId}`);
const subcats = await response.json();
if (subCatList) {
if (subcats.length > 0) {
subCatList.innerHTML = '';
// Add "All [Parent]" option
const allSubBtn = document.createElement('button');
allSubBtn.type = 'button';
allSubBtn.className = 'btn btn-xs btn-outline-accent rounded-pill px-3 active-cat';
allSubBtn.textContent = 'All ' + this.textContent.trim();
allSubBtn.addEventListener('click', function() {
subCatList.querySelectorAll('button').forEach(b => b.classList.remove('active-cat'));
this.classList.add('active-cat');
filterItems(catId);
});
subCatList.appendChild(allSubBtn);
subcats.forEach(sc => {
const scBtn = document.createElement('button');
scBtn.type = 'button';
scBtn.className = 'btn btn-xs btn-outline-accent rounded-pill px-3';
scBtn.textContent = sc.name;
scBtn.addEventListener('click', function() {
subCatList.querySelectorAll('button').forEach(b => b.classList.remove('active-cat'));
this.classList.add('active-cat');
filterItems(catId, sc.id.toString());
});
subCatList.appendChild(scBtn);
});
subCatList.classList.remove('d-none');
} else {
subCatList.classList.add('d-none');
}
}
} catch (error) {
console.error('Error fetching subcategories:', error);
}
filterItems(catId);
});
});
updateBadge();
});
</script>
{% endblock %}

View File

@ -0,0 +1,375 @@
{% extends "base.html" %}
{% load static %}
{% block content %}
<div class="container-fluid py-2">
<!-- Header -->
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0 fw-bold">{{ title }}</h4>
<a href="{% url 'new_fit' %}" class="btn btn-sm btn-outline-lavender rounded-pill px-3">
<i class="fas fa-plus me-1"></i> <span class="d-none d-sm-inline">New Fit</span><span class="d-inline d-sm-none">New</span>
</a>
</div>
<!-- Filter Toggle and Sort Bars -->
<div class="mb-3">
<button class="btn btn-outline-secondary btn-sm rounded-pill px-3 mb-2" type="button" data-bs-toggle="collapse" data-bs-target="#filterCollapse" aria-expanded="false">
<i class="bi bi-filter me-1"></i> Filter
</button>
<div class="collapse {% if sort != 'recent' %}show{% endif %}" id="filterCollapse">
<div class="d-flex flex-column gap-2 mt-2">
<a href="?sort=recent{% if current_folder %}&folder={{ current_folder.id }}{% endif %}{% if request.GET.q %}&q={{ request.GET.q }}{% endif %}"
class="btn {% if sort == 'recent' %}btn-lavender{% else %}btn-outline-secondary{% endif %} btn-sm py-2 text-start px-3 rounded-3 border-0 shadow-sm">
Most Recently Added
</a>
<a href="?sort=alpha{% if current_folder %}&folder={{ current_folder.id }}{% endif %}{% if request.GET.q %}&q={{ request.GET.q }}{% endif %}"
class="btn {% if sort == 'alpha' %}btn-lavender{% else %}btn-outline-secondary{% endif %} btn-sm py-2 text-start px-3 rounded-3 border-0 shadow-sm">
A-Z
</a>
</div>
</div>
</div>
<!-- Category Tabs (Folders) -->
<div class="category-tabs-container mb-4 overflow-auto">
<ul class="nav nav-pills flex-nowrap pb-2 gap-2" id="folder-tabs" role="tablist" style="min-width: max-content;">
<li class="nav-item" role="presentation">
<a href="{% url 'outfit_list' %}?sort={{ sort }}" class="nav-link rounded-pill {% if not current_folder %}active bg-lavender{% else %}bg-secondary text-white{% endif %} py-1 px-3 small">
All Outfits
</a>
</li>
{% for folder in folders %}
<li class="nav-item" role="presentation">
<a href="{% url 'outfit_list' %}?folder={{ folder.id }}&sort={{ sort }}" class="nav-link rounded-pill {% if current_folder.id == folder.id %}active bg-lavender{% else %}bg-secondary text-white{% endif %} py-1 px-3 small">
{{ folder.name }}
</a>
</li>
{% endfor %}
</ul>
</div>
<!-- Search -->
<form method="GET" class="mb-4">
{% if current_folder %}<input type="hidden" name="folder" value="{{ current_folder.id }}">{% endif %}
<input type="hidden" name="sort" value="{{ sort }}">
<div class="input-group input-group-sm glass-card p-1">
<span class="input-group-text bg-transparent border-0 text-dim"><i class="fas fa-search"></i></span>
<input type="text" name="q" class="form-control bg-transparent border-0 text-white" placeholder="Search outfits..." value="{{ request.GET.q }}">
</div>
</form>
<!-- Outfit Grid -->
<div class="row g-4">
{% for outfit in outfits %}
<div class="col-12 col-md-6 col-lg-4">
<div class="glass-card outfit-card h-100 overflow-hidden position-relative border border-secondary shadow-lg long-press-target"
data-outfit-id="{{ outfit.id }}" data-outfit-name="{{ outfit.name|default:'Unnamed Outfit' }}">
<div class="outfit-collage-container p-3" data-bs-toggle="modal" data-bs-target="#previewModal{{ outfit.id }}" style="cursor: pointer;">
<div class="rounded bg-secondary position-relative overflow-hidden mb-3 shadow-inner d-flex align-items-center justify-content-center p-2" style="height: 180px;">
<div class="d-flex align-items-center justify-content-center h-100 w-100 overflow-hidden" style="gap: 8px;">
{% for item in outfit.items.all|slice:":4" %}
<img src="{{ item.image.url }}" class="outfit-item-square shadow-sm" alt="{{ item.name }}">
{% endfor %}
{% if outfit.accessories.exists %}
{% for acc in outfit.accessories.all|slice:":2" %}
<img src="{{ acc.image.url }}" class="outfit-item-square rounded-circle shadow-sm" alt="{{ acc.name }}">
{% endfor %}
{% endif %}
</div>
{% if not outfit.items.exists and not outfit.accessories.exists %}
<div class="w-100 h-100 d-flex align-items-center justify-content-center bg-dark">
<i class="fas fa-tshirt text-dim fa-3x"></i>
</div>
{% endif %}
</div>
<div class="d-flex justify-content-between align-items-start pe-2">
<div>
<h6 class="fw-bold mb-1 text-white text-truncate" style="max-width: 200px;">{{ outfit.name|default:"Unnamed Outfit" }}</h6>
<div class="small text-dim">
<i class="fas fa-tshirt me-1"></i> {{ outfit.items.count }}
{% if outfit.accessories.exists %}
<i class="fas fa-gem ms-2 me-1"></i> {{ outfit.accessories.count }}
{% endif %}
</div>
</div>
</div>
</div>
<!-- Quick Schedule Button -->
<div class="p-3 pt-0 border-top border-secondary mt-2">
<button class="btn btn-sm btn-lavender w-100 py-2 mt-2" data-bs-toggle="modal" data-bs-target="#scheduleModal{{ outfit.id }}">
<i class="far fa-calendar-alt me-2"></i> Schedule
</button>
</div>
</div>
</div>
<!-- Preview Modal -->
<div class="modal fade" id="previewModal{{ outfit.id }}" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-lg">
<div class="modal-content bg-dark text-white border-secondary">
<div class="modal-header border-secondary">
<h5 class="modal-title fw-bold">{{ outfit.name|default:"Outfit Details" }}</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="row g-4">
<div class="col-md-7">
<div class="small text-dim mb-3 text-uppercase tracking-wider fw-bold">Clothing Items</div>
<div class="row g-2">
{% for item in outfit.items.all %}
<div class="col-4">
<div class="rounded bg-secondary overflow-hidden shadow-sm" style="aspect-ratio: 1/1;">
<img src="{{ item.image.url }}" class="w-100 h-100 object-fit-contain" title="{{ item.name }}">
</div>
<div class="small text-center mt-1 text-truncate" style="font-size: 0.7rem;">{{ item.name }}</div>
</div>
{% endfor %}
</div>
</div>
<div class="col-md-5">
{% if outfit.accessories.exists %}
<div class="mb-4">
<div class="small text-dim mb-3 text-uppercase tracking-wider fw-bold">Accessories</div>
<div class="row g-2">
{% for acc in outfit.accessories.all %}
<div class="col-6">
<div class="rounded-circle bg-secondary overflow-hidden shadow-sm" style="aspect-ratio: 1/1;">
<img src="{{ acc.image.url }}" class="w-100 h-100 object-fit-cover" title="{{ acc.name }}">
</div>
<div class="small text-center mt-1 text-truncate" style="font-size: 0.7rem;">{{ acc.name }}</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
<div class="card bg-secondary border-0 p-3 mb-3">
<div class="small text-dim mb-1">Created on</div>
<div class="fw-bold">{{ outfit.date_created|date:"M d, Y" }}</div>
</div>
<button class="btn btn-lavender w-100 py-3 fw-bold shadow" data-bs-toggle="modal" data-bs-target="#scheduleModal{{ outfit.id }}">
<i class="far fa-calendar-alt me-2"></i> Schedule Now
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Schedule Modal -->
<div class="modal fade" id="scheduleModal{{ outfit.id }}" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content bg-dark text-white border-secondary">
<div class="modal-header border-secondary">
<h5 class="modal-title fw-bold">Schedule Outfit</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body p-0">
<div class="list-group list-group-flush">
{% for day in week_data %}
<form action="{% url 'assign_outfit' %}" method="post">
{% csrf_token %}
<input type="hidden" name="outfit_id" value="{{ outfit.id }}">
<input type="hidden" name="date" value="{{ day.date }}">
<button type="submit" class="list-group-item list-group-item-action bg-dark text-white border-secondary d-flex justify-content-between align-items-center py-3">
<div>
<span class="fw-bold">{{ day.day }}</span>
<span class="small text-dim ms-3">{{ day.date }}</span>
</div>
<i class="fas fa-calendar-plus text-lavender fa-lg"></i>
</button>
</form>
{% endfor %}
</div>
</div>
</div>
</div>
</div>
{% empty %}
<div class="col-12 text-center py-5">
<div class="mb-3">
<i class="fas fa-tshirt fa-4x text-dim opacity-25"></i>
</div>
<h5 class="text-white">No outfits found.</h5>
<p class="text-dim">Create your first stylish combination!</p>
<a href="{% url 'new_fit' %}" class="btn btn-lavender rounded-pill px-4">Create Outfit</a>
</div>
{% endfor %}
</div>
</div>
<!-- Long Press Options Modal -->
<div class="modal fade" id="optionsModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-sm">
<div class="modal-content bg-dark text-white border-secondary">
<div class="modal-body p-0">
<div class="list-group list-group-flush">
<button type="button" class="list-group-item list-group-item-action bg-dark text-white border-secondary py-3 text-center fw-bold" id="btn-categorize">
<i class="fas fa-folder-plus me-2 text-lavender"></i> Categorize
</button>
<button type="button" class="list-group-item list-group-item-action bg-dark text-white border-secondary py-3 text-center fw-bold text-danger" id="btn-show-delete-confirm">
<i class="fas fa-trash-alt me-2"></i> Delete
</button>
<button type="button" class="list-group-item list-group-item-action bg-dark text-white border-secondary py-3 text-center small text-dim" data-bs-dismiss="modal">
Cancel
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Categorize Modal -->
<div class="modal fade" id="categorizeModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content bg-dark text-white border-secondary">
<div class="modal-header border-secondary">
<h5 class="modal-title fw-bold">Categorize Outfit</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<form id="move-outfit-form" method="post">
{% csrf_token %}
<div class="mb-3">
<label class="form-label text-dim small text-uppercase">Select Group</label>
<select name="folder_id" class="form-select bg-secondary text-white border-secondary">
<option value="">No Group</option>
{% for folder in all_folders %}
<option value="{{ folder.id }}">{{ folder.name }}</option>
{% endfor %}
</select>
</div>
<div class="text-center my-2 text-dim small">- OR -</div>
<div class="mb-4">
<label class="form-label text-dim small text-uppercase">New Group Name</label>
<input type="text" name="new_folder_name" class="form-control bg-secondary text-white border-secondary" placeholder="e.g. Work, Vacation">
</div>
<button type="submit" class="btn btn-lavender w-100 py-2">Save to Group</button>
</form>
</div>
</div>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div class="modal fade" id="deleteConfirmModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content bg-dark text-white border-secondary p-3">
<div class="modal-body text-center py-4">
<h4 class="fw-bold mb-4">Are you sure?</h4>
<div class="d-flex gap-3 mt-4">
<button type="button" class="btn btn-outline-secondary flex-grow-1 py-3 rounded-3" data-bs-dismiss="modal">Back</button>
<form id="delete-outfit-form" method="post" class="flex-grow-1">
{% csrf_token %}
<button type="submit" class="btn btn-danger w-100 py-3 rounded-3 fw-bold" style="background-color: #dc3545 !important;">Delete</button>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_css %}
<style>
.bg-lavender { background-color: var(--accent-lavender) !important; color: #fff !important; }
.text-lavender { color: var(--accent-lavender); }
.btn-outline-lavender { color: var(--accent-lavender); border-color: var(--accent-lavender); }
.btn-outline-lavender:hover { background-color: var(--accent-lavender); color: #fff; }
.btn-lavender { background-color: var(--accent-lavender); color: #fff; border: none; }
.btn-lavender:hover { opacity: 0.9; transform: translateY(-1px); }
.outfit-card { transition: all 0.3s ease; border-radius: 15px !important; }
.outfit-card:hover { transform: translateY(-5px); box-shadow: 0 10px 20px rgba(0,0,0,0.3) !important; }
.shadow-inner { box-shadow: inset 0 2px 4px rgba(0,0,0,0.5); }
.border-secondary { border-color: rgba(255,255,255,0.1) !important; }
.outfit-item-square {
height: 80%;
max-width: 25%;
aspect-ratio: 1/1;
object-fit: contain;
background-color: rgba(255,255,255,0.05);
border-radius: 8px;
transition: transform 0.2s;
border: 1px solid rgba(255,255,255,0.1);
}
.long-press-target {
-webkit-touch-callout: none;
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
</style>
{% endblock %}
{% block extra_js %}
<script>
document.addEventListener('DOMContentLoaded', function() {
let longPressTimer;
const longPressDuration = 600; // ms
let currentOutfitId = null;
let currentOutfitName = '';
const optionsModal = new bootstrap.Modal(document.getElementById('optionsModal'));
const categorizeModal = new bootstrap.Modal(document.getElementById('categorizeModal'));
const deleteConfirmModal = new bootstrap.Modal(document.getElementById('deleteConfirmModal'));
const targets = document.querySelectorAll('.long-press-target');
targets.forEach(target => {
function startPress(e) {
longPressTimer = setTimeout(() => {
currentOutfitId = target.dataset.outfitId;
currentOutfitName = target.dataset.outfitName;
optionsModal.show();
// Add haptic feedback if available
if (window.navigator && window.navigator.vibrate) {
window.navigator.vibrate(50);
}
}, longPressDuration);
}
function cancelPress() {
clearTimeout(longPressTimer);
}
target.addEventListener('touchstart', startPress);
target.addEventListener('touchend', cancelPress);
target.addEventListener('touchmove', cancelPress);
target.addEventListener('mousedown', startPress);
target.addEventListener('mouseup', cancelPress);
target.addEventListener('mouseleave', cancelPress);
// Also prevent standard context menu on long press for mobile
target.addEventListener('contextmenu', (e) => {
e.preventDefault();
});
});
document.getElementById('btn-categorize').addEventListener('click', function() {
optionsModal.hide();
const form = document.getElementById('move-outfit-form');
form.action = `/outfits/${currentOutfitId}/move/`;
categorizeModal.show();
});
document.getElementById('btn-show-delete-confirm').addEventListener('click', function() {
optionsModal.hide();
const form = document.getElementById('delete-outfit-form');
form.action = `/outfits/${currentOutfitId}/delete/`;
deleteConfirmModal.show();
});
});
</script>
{% endblock %}

View File

@ -0,0 +1,205 @@
{% extends 'base.html' %}
{% load static %}
{% block content %}
<div class="container-fluid py-2">
<!-- Header -->
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0 fw-bold">Wardrobe</h4>
<div class="d-flex gap-2">
<!-- View Toggle -->
<div class="btn-group btn-group-sm bg-secondary rounded-pill p-1 shadow-sm me-2">
<button type="button" class="btn btn-sm rounded-pill px-3 active" id="view-grid" title="Grid View">
<i class="fas fa-th-large"></i>
</button>
<button type="button" class="btn btn-sm rounded-pill px-3" id="view-list" title="List View">
<i class="fas fa-list"></i>
</button>
</div>
<a href="{% url 'add_wardrobe_item' %}" class="btn btn-accent btn-sm rounded-pill px-3 shadow-sm d-flex align-items-center">
<i class="fas fa-plus me-1"></i> <span class="d-none d-sm-inline">Add Item</span><span class="d-inline d-sm-none">Add</span>
</a>
</div>
</div>
<!-- Filter Toggle and Sort Bars -->
<div class="mb-3">
<button class="btn btn-outline-secondary btn-sm rounded-pill px-3 mb-2" type="button" data-bs-toggle="collapse" data-bs-target="#filterCollapse" aria-expanded="false">
<i class="fas fa-filter me-1"></i> Filter & Sort
</button>
<div class="collapse {% if sort != 'recent' or request.GET.q %}show{% endif %}" id="filterCollapse">
<div class="glass-card p-3 rounded-3 mb-3">
<form method="get" class="mb-3">
{% if current_main %}<input type="hidden" name="main_category" value="{{ current_main.id }}">{% endif %}
{% if current_sub %}<input type="hidden" name="subcategory" value="{{ current_sub.id }}">{% endif %}
<input type="hidden" name="sort" value="{{ sort }}">
<div class="input-group input-group-sm bg-secondary rounded-pill px-2">
<span class="input-group-text bg-transparent border-0 text-dim"><i class="fas fa-search"></i></span>
<input type="text" name="q" class="form-control bg-transparent border-0 text-white" placeholder="Search items..." value="{{ request.GET.q }}">
{% if request.GET.q %}
<a href="?sort={{ sort }}{% if current_main %}&main_category={{ current_main.id }}{% endif %}{% if current_sub %}&subcategory={{ current_sub.id }}{% endif %}" class="btn btn-link text-dim p-0 me-2 d-flex align-items-center">
<i class="fas fa-times-circle"></i>
</a>
{% endif %}
</div>
</form>
<div class="d-flex flex-wrap gap-2">
<a href="?sort=recent{% if current_main %}&main_category={{ current_main.id }}{% endif %}{% if current_sub %}&subcategory={{ current_sub.id }}{% endif %}{% if request.GET.q %}&q={{ request.GET.q }}{% endif %}"
class="btn {% if sort == 'recent' %}btn-accent{% else %}btn-outline-secondary{% endif %} btn-xs rounded-pill px-3">
Newest
</a>
<a href="?sort=alpha{% if current_main %}&main_category={{ current_main.id }}{% endif %}{% if current_sub %}&subcategory={{ current_sub.id }}{% endif %}{% if request.GET.q %}&q={{ request.GET.q }}{% endif %}"
class="btn {% if sort == 'alpha' %}btn-accent{% else %}btn-outline-secondary{% endif %} btn-xs rounded-pill px-3">
A-Z
</a>
</div>
</div>
</div>
</div>
<!-- Main Categories Tabs -->
<div class="d-flex gap-2 overflow-auto no-scrollbar pb-3 mb-2">
<a class="btn btn-xs rounded-pill px-3 {% if not current_main %}btn-accent{% else %}btn-outline-secondary{% endif %}" href="?sort={{ sort }}">All</a>
{% for main_cat in main_categories %}
<a class="btn btn-xs rounded-pill px-3 {% if current_main.id == main_cat.id %}btn-accent{% else %}btn-outline-secondary{% endif %}"
href="?main_category={{ main_cat.id }}&sort={{ sort }}">{{ main_cat.name }}</a>
{% endfor %}
</div>
<!-- Subcategories Tabs -->
{% if current_main and subcategories %}
<div class="d-flex gap-2 overflow-auto no-scrollbar pb-3 mb-3">
<a href="?main_category={{ current_main.id }}&sort={{ sort }}"
class="btn btn-xs {% if not current_sub %}btn-secondary{% else %}btn-outline-secondary{% endif %} rounded-pill px-3">
All {{ current_main.name }}
</a>
{% for sub in subcategories %}
<a href="?main_category={{ current_main.id }}&subcategory={{ sub.id }}&sort={{ sort }}"
class="btn btn-xs {% if current_sub.id == sub.id %}btn-secondary{% else %}btn-outline-secondary{% endif %} rounded-pill px-3">
{{ sub.name }}
</a>
{% endfor %}
</div>
{% endif %}
<!-- Items Grid/List Container -->
<div class="row g-2" id="items-container">
{% for item in items %}
<div class="item-wrapper col-4 col-sm-3 col-md-2">
<div class="item-card position-relative rounded-1 d-flex flex-column align-items-center p-1" style="cursor: pointer; transition: all 0.1s;">
<a href="{% url 'wardrobe_item_detail' item.pk %}" class="stretched-link"></a>
<!-- Grid Content (Default) -->
<div class="item-img-container position-relative rounded-1 overflow-hidden bg-secondary w-100 shadow-sm" style="aspect-ratio: 1/1;">
{% if item.image %}
<img src="{{ item.image.url }}" class="w-100 h-100 object-fit-contain">
{% else %}
<div class="w-100 h-100 d-flex align-items-center justify-content-center">
<i class="fas fa-image text-muted"></i>
</div>
{% endif %}
</div>
<div class="item-name-label mt-1 text-center small text-truncate w-100 px-1" style="font-size: 0.65rem; color: var(--text-main);">
{{ item.name|default:"Unnamed" }}
</div>
<!-- List Content (Hidden) -->
<div class="item-list-content d-none w-100 align-items-center gap-2 px-1 py-1">
<div class="list-img-box rounded-1 bg-secondary overflow-hidden" style="width: 28px; height: 28px; flex-shrink: 0;">
{% if item.image %}
<img src="{{ item.image.url }}" class="w-100 h-100 object-fit-cover">
{% else %}
<div class="w-100 h-100 d-flex align-items-center justify-content-center">
<i class="fas fa-image text-muted" style="font-size: 0.5rem;"></i>
</div>
{% endif %}
</div>
<div class="flex-grow-1 text-truncate">
<div class="small fw-bold text-truncate" style="font-size: 0.75rem;">{{ item.name|default:"Unnamed" }}</div>
<div class="text-dim text-truncate" style="font-size: 0.6rem;">{{ item.category.name|default:"Uncategorized" }}</div>
</div>
</div>
</div>
</div>
{% empty %}
<div class="col-12 text-center py-5">
<i class="fas fa-box-open text-dim mb-2" style="font-size: 3rem;"></i>
<p class="text-dim">No items found in your wardrobe.</p>
</div>
{% endfor %}
</div>
</div>
{% endblock %}
{% block extra_css %}
<style>
.item-card:hover {
background-color: rgba(255, 255, 255, 0.08);
transform: translateY(-2px);
}
.list-mode .item-card {
flex-direction: row !important;
align-items: center !important;
padding: 4px 8px !important;
}
.list-mode .item-img-container,
.list-mode .item-name-label {
display: none !important;
}
.list-mode .item-list-content {
display: flex !important;
}
.no-scrollbar::-webkit-scrollbar { display: none; }
.object-fit-contain { object-fit: contain; }
.object-fit-cover { object-fit: cover; }
.btn-xs {
padding: 0.25rem 0.5rem;
font-size: 0.7rem;
}
</style>
{% endblock %}
{% block extra_js %}
<script>
document.addEventListener('DOMContentLoaded', function() {
const viewGrid = document.getElementById('view-grid');
const viewList = document.getElementById('view-list');
const itemsContainer = document.getElementById('items-container');
const wrappers = document.querySelectorAll('.item-wrapper');
if (viewGrid && viewList) {
viewGrid.addEventListener('click', () => {
viewGrid.classList.add('active');
viewList.classList.remove('active');
itemsContainer.classList.remove('list-mode');
wrappers.forEach(w => {
w.className = 'item-wrapper col-4 col-sm-3 col-md-2';
});
localStorage.setItem('wardrobe_view', 'grid');
});
viewList.addEventListener('click', () => {
viewList.classList.add('active');
viewGrid.classList.remove('active');
itemsContainer.classList.add('list-mode');
wrappers.forEach(w => {
w.className = 'item-wrapper col-12 col-sm-6 col-md-4';
});
localStorage.setItem('wardrobe_view', 'list');
});
// Restore preference
const savedView = localStorage.getItem('wardrobe_view');
if (savedView === 'list') {
viewList.click();
}
}
});
</script>
{% endblock %}

View File

@ -1,3 +1,18 @@
from django.test import TestCase from django.test import TestCase
from django.urls import reverse
from core.models import WardrobeItem, Accessory, Category
# Create your tests here. class HomeViewTest(TestCase):
def setUp(self):
self.category_w = Category.objects.create(name="T-Shirts", item_type='wardrobe')
self.category_a = Category.objects.create(name="Rings", item_type='accessory')
WardrobeItem.objects.create(name="Black Tee", category=self.category_w)
Accessory.objects.create(name="Silver Ring", category=self.category_a)
def test_home_view_status_code(self):
response = self.client.get(reverse('home'))
self.assertEqual(response.status_code, 200)
def test_home_view_item_count(self):
response = self.client.get(reverse('home'))
self.assertContains(response, "2") # Total items: 1 wardrobe + 1 accessory

View File

@ -1,7 +1,22 @@
from django.urls import path from django.urls import path
from . import views
from .views import home
urlpatterns = [ urlpatterns = [
path("", home, name="home"), path('', views.home, name='home'),
] path('wardrobe/', views.wardrobe_list, name='wardrobe_list'),
path('wardrobe/add/', views.add_wardrobe_item, name='add_wardrobe_item'),
path('wardrobe/<int:pk>/', views.wardrobe_item_detail, name='wardrobe_item_detail'),
path('wardrobe/<int:pk>/delete/', views.delete_wardrobe_item, name='delete_wardrobe_item'),
path('accessories/', views.accessory_list, name='accessory_list'),
path('accessories/add/', views.add_accessory_item, name='add_accessory_item'),
path('accessories/<int:pk>/', views.accessory_item_detail, name='accessory_item_detail'),
path('accessories/<int:pk>/delete/', views.delete_accessory_item, name='delete_accessory_item'),
path('outfits/', views.outfit_list, name='outfit_list'),
path('outfits/new/', views.new_fit, name='new_fit'),
path('outfits/<int:pk>/delete/', views.delete_outfit, name='delete_outfit'),
path('outfits/<int:pk>/move/', views.move_outfit, name='move_outfit'),
path('assign-outfit/', views.assign_outfit, name='assign_outfit'),
path('remove-assignment/<str:date>/', views.remove_assignment, name='remove_assignment'),
path('ajax/get-subcategories/', views.get_subcategories, name='get_subcategories'),
path('profile/update-avatar/', views.update_avatar, name='update_avatar'),
]

48
core/utils.py Normal file
View File

@ -0,0 +1,48 @@
from PIL import Image, ImageOps, ImageChops
import os
def process_clothing_image(image_path):
"""
Processes a clothing image:
1. Removes background (simplified version without ML if not available).
2. Crops to the garment boundaries.
3. Converts background to white.
"""
try:
img = Image.open(image_path).convert("RGBA")
# 1. Background removal / White background conversion
# Since we don't have rembg, we'll try a simple approach:
# If the background is already somewhat light, we can treat it as background.
# This is a fallback for true ML-based background removal.
datas = img.getdata()
new_data = []
# Simple heuristic: pixels that are very bright or very dark (if it's a studio shot)
# For more precision, we'd need a real segmentation model.
for item in datas:
# If the pixel is very white, make it transparent for cropping logic
if item[0] > 240 and item[1] > 240 and item[2] > 240:
new_data.append((255, 255, 255, 0))
else:
new_data.append(item)
img.putdata(new_data)
# 2. Automatic Cropping
# Get the bounding box of non-transparent areas
bbox = img.getbbox()
if bbox:
img = img.crop(bbox)
# 3. Add White Background
background = Image.new("RGB", img.size, (255, 255, 255))
background.paste(img, mask=img.split()[3]) # 3 is the alpha channel
# Save back to the same path or a new one
background.save(image_path, "JPEG", quality=95)
return True
except Exception as e:
print(f"Error processing image {image_path}: {e}")
return False

View File

@ -1,25 +1,369 @@
import os import datetime
import platform import json
from django.shortcuts import render, redirect, get_object_or_404
from django import get_version as django_version from django.db.models import Count, Q
from django.shortcuts import render from django.views.decorators.http import require_POST
from django.utils import timezone from django.http import JsonResponse
from django.contrib.auth.models import User
from .models import Category, WardrobeItem, Accessory, Outfit, OutfitFolder, CalendarAssignment, UserProfile
from .forms import WardrobeItemForm, AccessoryForm, OutfitForm, UserProfileForm
def get_current_user_profile(request):
if request.user.is_authenticated:
profile, _ = UserProfile.objects.get_or_create(user=request.user)
return profile
else:
# Fallback to first user if not authenticated (common for simple dev setups)
user = User.objects.first()
if user:
profile, _ = UserProfile.objects.get_or_create(user=user)
return profile
return None
def home(request): def home(request):
"""Render the landing screen with loader and environment details.""" """Dashboard view with current week calendar and total items count."""
host_name = request.get_host().lower() season_filter = request.GET.get('season', 'summer')
agent_brand = "AppWizzy" if host_name == "appwizzy.com" else "Flatlogic"
now = timezone.now() total_wardrobe = WardrobeItem.objects.count()
total_accessories = Accessory.objects.count()
total_items = total_wardrobe + total_accessories
today = datetime.date.today()
start_of_week = today - datetime.timedelta(days=today.weekday())
week_dates = [start_of_week + datetime.timedelta(days=i) for i in range(7)]
day_names = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
calendar_data = []
assignments = {a.date: a.outfit for a in CalendarAssignment.objects.filter(date__in=week_dates)}
for i, date in enumerate(week_dates):
calendar_data.append({
'day': day_names[i],
'date': date,
'outfit': assignments.get(date)
})
# All outfits for the "quick add" modal on home screen
all_outfits = Outfit.objects.all().order_by('-date_created')
profile = get_current_user_profile(request)
avatar_form = UserProfileForm(instance=profile)
context = { context = {
"project_name": "New Style", 'total_items': total_items,
"agent_brand": agent_brand, 'calendar_data': calendar_data,
"django_version": django_version(), 'today': today,
"python_version": platform.python_version(), 'season_filter': season_filter,
"current_time": now, 'all_outfits': all_outfits,
"host_name": host_name, 'week_dates_json': json.dumps([d.strftime('%Y-%m-%d') for d in week_dates]),
"project_description": os.getenv("PROJECT_DESCRIPTION", ""), 'day_names': day_names,
"project_image_url": os.getenv("PROJECT_IMAGE_URL", ""), 'profile': profile,
'avatar_form': avatar_form,
} }
return render(request, "core/index.html", context) return render(request, 'core/index.html', context)
@require_POST
def update_avatar(request):
profile = get_current_user_profile(request)
if profile:
form = UserProfileForm(request.POST, request.FILES, instance=profile)
if form.is_valid():
form.save()
return redirect('home')
@require_POST
def remove_assignment(request, date):
CalendarAssignment.objects.filter(date=date).delete()
return redirect('home')
@require_POST
def assign_outfit(request):
outfit_id = request.POST.get('outfit_id')
date_str = request.POST.get('date')
if outfit_id and date_str:
outfit = get_object_or_404(Outfit, id=outfit_id)
try:
date_obj = datetime.datetime.strptime(date_str, '%Y-%m-%d').date()
CalendarAssignment.objects.update_or_create(
date=date_obj,
defaults={'outfit': outfit}
)
except ValueError:
pass
return redirect(request.META.get('HTTP_REFERER', 'home'))
def wardrobe_list(request):
query = request.GET.get('q')
main_category_id = request.GET.get('main_category')
subcategory_id = request.GET.get('subcategory')
sort = request.GET.get('sort', 'recent')
main_categories = Category.objects.filter(item_type='wardrobe', parent=None).annotate(
num_items=Count('wardrobe_items', distinct=True),
num_child_items=Count('children__wardrobe_items', distinct=True)
).filter(Q(is_preset=True) | Q(num_items__gt=0) | Q(num_child_items__gt=0))
subcategories = None
current_main = None
current_sub = None
if sort == 'alpha':
items = WardrobeItem.objects.all().order_by('name')
else:
items = WardrobeItem.objects.all().order_by('-date_added')
if main_category_id:
current_main = get_object_or_404(Category, id=main_category_id, parent=None)
subcategories = Category.objects.filter(parent=current_main).annotate(
num_items=Count('wardrobe_items', distinct=True)
).filter(Q(is_preset=True) | Q(num_items__gt=0))
items = items.filter(Q(category=current_main) | Q(category__parent=current_main))
if subcategory_id:
current_sub = get_object_or_404(Category, id=subcategory_id, parent=current_main)
items = items.filter(category=current_sub)
if query:
items = items.filter(Q(name__icontains=query) | Q(category__name__icontains=query))
context = {
'main_categories': main_categories,
'subcategories': subcategories,
'current_main': current_main,
'current_sub': current_sub,
'items': items,
'title': 'Wardrobe',
'sort': sort
}
return render(request, 'core/wardrobe_list.html', context)
def add_wardrobe_item(request):
if request.method == 'POST':
form = WardrobeItemForm(request.POST, request.FILES)
if form.is_valid():
form.save()
return redirect('wardrobe_list')
else:
form = WardrobeItemForm()
return render(request, 'core/item_form.html', {'form': form, 'title': 'Add Wardrobe Item', 'type': 'wardrobe'})
def accessory_list(request):
query = request.GET.get('q')
main_category_id = request.GET.get('main_category')
subcategory_id = request.GET.get('subcategory')
sort = request.GET.get('sort', 'recent')
main_categories = Category.objects.filter(item_type='accessory', parent=None).annotate(
num_items=Count('accessories', distinct=True),
num_child_items=Count('children__accessories', distinct=True)
).filter(Q(is_preset=True) | Q(num_items__gt=0) | Q(num_child_items__gt=0))
subcategories = None
current_main = None
current_sub = None
if sort == 'alpha':
items = Accessory.objects.all().order_by('name')
else:
items = Accessory.objects.all().order_by('-date_added')
if main_category_id:
current_main = get_object_or_404(Category, id=main_category_id, parent=None)
subcategories = Category.objects.filter(parent=current_main).annotate(
num_items=Count('accessories', distinct=True)
).filter(Q(is_preset=True) | Q(num_items__gt=0))
items = items.filter(Q(category=current_main) | Q(category__parent=current_main))
if subcategory_id:
current_sub = get_object_or_404(Category, id=subcategory_id, parent=current_main)
items = items.filter(category=current_sub)
if query:
items = items.filter(Q(name__icontains=query) | Q(category__name__icontains=query))
context = {
'main_categories': main_categories,
'subcategories': subcategories,
'current_main': current_main,
'current_sub': current_sub,
'items': items,
'title': 'Accessories',
'sort': sort
}
return render(request, 'core/accessory_list.html', context)
def add_accessory_item(request):
if request.method == 'POST':
form = AccessoryForm(request.POST, request.FILES)
if form.is_valid():
form.save()
return redirect('accessory_list')
else:
form = AccessoryForm()
return render(request, 'core/item_form.html', {'form': form, 'title': 'Add Accessory', 'type': 'accessory'})
def outfit_list(request):
query = request.GET.get('q')
folder_id = request.GET.get('folder')
sort = request.GET.get('sort', 'recent')
def get_valid_folders(parent=None):
return OutfitFolder.objects.filter(parent=parent).annotate(
num_outfits=Count('outfits', distinct=True),
num_child_outfits=Count('children__outfits', distinct=True)
).filter(Q(is_preset=True) | Q(num_outfits__gt=0) | Q(num_child_outfits__gt=0))
folders = get_valid_folders(None)
if sort == 'alpha':
outfits = Outfit.objects.all().order_by('name')
else:
outfits = Outfit.objects.all().order_by('-date_created')
if folder_id:
current_folder = get_object_or_404(OutfitFolder, id=folder_id)
folders = get_valid_folders(current_folder)
if not folders.exists():
folders = get_valid_folders(current_folder.parent)
outfits = outfits.filter(Q(folder=current_folder) | Q(folder__parent=current_folder))
else:
current_folder = None
if query:
outfits = outfits.filter(Q(name__icontains=query))
# For weekday assignment in the list view
today = datetime.date.today()
start_of_week = today - datetime.timedelta(days=today.weekday())
week_dates = [start_of_week + datetime.timedelta(days=i) for i in range(7)]
day_names = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
week_data = [{'day': day_names[i], 'date': week_dates[i].strftime('%Y-%m-%d')} for i in range(7)]
# All folders for categorization
all_folders = OutfitFolder.objects.all().order_by('name')
context = {
'outfits': outfits,
'folders': folders,
'all_folders': all_folders,
'current_folder': current_folder,
'title': 'Outfits',
'week_data': week_data,
'sort': sort
}
return render(request, 'core/outfit_list.html', context)
def new_fit(request):
date_str = request.GET.get('date')
if request.method == 'POST':
form = OutfitForm(request.POST)
if form.is_valid():
outfit = form.save()
if date_str:
try:
date_obj = datetime.datetime.strptime(date_str, '%Y-%m-%d').date()
CalendarAssignment.objects.update_or_create(
date=date_obj,
defaults={'outfit': outfit}
)
return redirect('home')
except ValueError:
pass
return redirect('outfit_list')
else:
form = OutfitForm()
wardrobe_items = WardrobeItem.objects.all().order_by('-date_added')
accessories = Accessory.objects.all().order_by('-date_added')
main_categories = Category.objects.filter(parent=None)
return render(request, 'core/new_fit.html', {
'form': form,
'title': f'New Fit for {date_str}' if date_str else 'New Fit',
'wardrobe_items': wardrobe_items,
'accessories': accessories,
'main_categories': main_categories,
'date_str': date_str
})
def get_subcategories(request):
parent_id = request.GET.get('parent_id')
if parent_id:
subcategories = Category.objects.filter(parent_id=parent_id).values('id', 'name')
return JsonResponse(list(subcategories), safe=False)
return JsonResponse([], safe=False)
def wardrobe_item_detail(request, pk):
item = get_object_or_404(WardrobeItem, pk=pk)
return render(request, 'core/item_detail.html', {'item': item, 'type': 'wardrobe'})
def accessory_item_detail(request, pk):
item = get_object_or_404(Accessory, pk=pk)
return render(request, 'core/item_detail.html', {'item': item, 'type': 'accessory'})
def cleanup_category(category):
if category and not category.is_preset:
if not category.wardrobe_items.exists() and not category.accessories.exists() and not category.children.exists():
parent = category.parent
category.delete()
if parent:
cleanup_category(parent)
def cleanup_folder(folder):
if folder and not folder.is_preset:
if not folder.outfits.exists() and not folder.children.exists():
parent = folder.parent
folder.delete()
if parent:
cleanup_folder(parent)
@require_POST
def delete_wardrobe_item(request, pk):
item = get_object_or_404(WardrobeItem, pk=pk)
category = item.category
item.delete()
cleanup_category(category)
return redirect('wardrobe_list')
@require_POST
def delete_accessory_item(request, pk):
item = get_object_or_404(Accessory, pk=pk)
category = item.category
item.delete()
cleanup_category(category)
return redirect('accessory_list')
@require_POST
def delete_outfit(request, pk):
outfit = get_object_or_404(Outfit, pk=pk)
folder = outfit.folder
outfit.delete()
cleanup_folder(folder)
return redirect('outfit_list')
@require_POST
def move_outfit(request, pk):
outfit = get_object_or_404(Outfit, pk=pk)
folder_id = request.POST.get('folder_id')
new_folder_name = request.POST.get('new_folder_name')
old_folder = outfit.folder
if new_folder_name:
folder = OutfitFolder.objects.create(name=new_folder_name)
outfit.folder = folder
elif folder_id:
folder = get_object_or_404(OutfitFolder, id=folder_id)
outfit.folder = folder
else:
outfit.folder = None
outfit.save()
cleanup_folder(old_folder)
return redirect('outfit_list')

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

BIN
media/wardrobe/images.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

View File

@ -1,3 +1,4 @@
Django==5.2.7 Django==5.2.7
mysqlclient==2.2.7 mysqlclient==2.2.7
python-dotenv==1.1.1 python-dotenv==1.1.1
Pillow==10.2.0