Autosave: 20260204-202734
This commit is contained in:
parent
65bfca78e9
commit
88ba420f9e
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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'
|
||||||
@ -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.
Binary file not shown.
BIN
core/__pycache__/forms.cpython-311.pyc
Normal file
BIN
core/__pycache__/forms.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
core/__pycache__/tests.cpython-311.pyc
Normal file
BIN
core/__pycache__/tests.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
core/__pycache__/utils.cpython-311.pyc
Normal file
BIN
core/__pycache__/utils.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
166
core/forms.py
Normal file
166
core/forms.py
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
from django import forms
|
||||||
|
from .models import WardrobeItem, Accessory, Outfit, Category, OutfitFolder
|
||||||
|
|
||||||
|
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
|
||||||
0
core/management/__init__.py
Normal file
0
core/management/__init__.py
Normal file
BIN
core/management/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
core/management/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
0
core/management/commands/__init__.py
Normal file
0
core/management/commands/__init__.py
Normal file
BIN
core/management/commands/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
core/management/commands/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
44
core/management/commands/setup_categories.py
Normal file
44
core/management/commands/setup_categories.py
Normal 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'))
|
||||||
81
core/migrations/0001_initial.py
Normal file
81
core/migrations/0001_initial.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
core/migrations/0003_outfitfolder_is_preset.py
Normal file
18
core/migrations/0003_outfitfolder_is_preset.py
Normal 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),
|
||||||
|
),
|
||||||
|
]
|
||||||
BIN
core/migrations/__pycache__/0001_initial.cpython-311.pyc
Normal file
BIN
core/migrations/__pycache__/0001_initial.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
104
core/models.py
104
core/models.py
@ -1,3 +1,105 @@
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
|
import os
|
||||||
|
from .utils import process_clothing_image
|
||||||
|
|
||||||
# Create your models here.
|
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}"
|
||||||
|
|||||||
@ -1,25 +1,128 @@
|
|||||||
|
{% 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">
|
||||||
{% if project_description %}
|
<title>{% block title %}Wardrobe Planner{% endblock %}</title>
|
||||||
<meta name="description" content="{{ project_description }}">
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
<meta property="og:description" content="{{ project_description }}">
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css">
|
||||||
<meta property="twitter:description" content="{{ project_description }}">
|
<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">
|
||||||
{% if project_image_url %}
|
<style>
|
||||||
<meta property="og:image" content="{{ project_image_url }}">
|
:root {
|
||||||
<meta property="twitter:image" content="{{ project_image_url }}">
|
--bg-dark: #121212;
|
||||||
{% endif %}
|
--surface-dark: #1E1E1E;
|
||||||
{% load static %}
|
--primary-accent: #BB86FC;
|
||||||
<link rel="stylesheet" href="{% static 'css/custom.css' %}?v={{ deployment_timestamp }}">
|
--secondary-accent: #03DAC6;
|
||||||
{% block head %}{% endblock %}
|
--text-main: #E0E0E0;
|
||||||
|
--text-dim: #9E9E9E;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
background-color: var(--bg-dark);
|
||||||
|
color: var(--text-main);
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
padding-bottom: 80px;
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
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 '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>
|
||||||
|
<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>
|
||||||
|
</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>
|
||||||
94
core/templates/core/accessory_list.html
Normal file
94
core/templates/core/accessory_list.html
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid py-4">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h1 class="h2 mb-0">Accessories</h1>
|
||||||
|
<a href="{% url 'add_accessory_item' %}" class="btn btn-primary shadow-sm">
|
||||||
|
<i class="bi bi-plus-lg"></i> Add Accessory
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Categories Tabs -->
|
||||||
|
<ul class="nav nav-pills mb-3 overflow-auto flex-nowrap pb-2" id="mainCatTabs" role="tablist">
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<a class="nav-link {% if not current_main %}active{% endif %}" href="{% url 'accessory_list' %}">All</a>
|
||||||
|
</li>
|
||||||
|
{% for main_cat in main_categories %}
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<a class="nav-link {% if current_main.id == main_cat.id %}active{% endif %}"
|
||||||
|
href="?main_category={{ main_cat.id }}">{{ main_cat.name }}</a>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<!-- Subcategories Tabs (if main category selected) -->
|
||||||
|
{% if current_main and subcategories %}
|
||||||
|
<div class="mb-4">
|
||||||
|
<div class="d-flex flex-wrap gap-2">
|
||||||
|
<a href="?main_category={{ current_main.id }}"
|
||||||
|
class="btn btn-sm {% if not current_sub %}btn-secondary{% else %}btn-outline-secondary{% endif %} rounded-pill">
|
||||||
|
All {{ current_main.name }}
|
||||||
|
</a>
|
||||||
|
{% for sub in subcategories %}
|
||||||
|
<a href="?main_category={{ current_main.id }}&subcategory={{ sub.id }}"
|
||||||
|
class="btn btn-sm {% if current_sub.id == sub.id %}btn-secondary{% else %}btn-outline-secondary{% endif %} rounded-pill">
|
||||||
|
{{ sub.name }}
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Search Bar -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<form method="get" class="d-flex shadow-sm">
|
||||||
|
{% 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="text" name="q" class="form-control bg-dark text-white border-secondary" placeholder="Search accessories..." value="{{ request.GET.q }}">
|
||||||
|
<button class="btn btn-secondary border-secondary" type="submit"><i class="bi bi-search"></i></button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Items Grid -->
|
||||||
|
<div class="row row-cols-2 row-cols-sm-3 row-cols-md-4 row-cols-lg-5 g-4">
|
||||||
|
{% for item in items %}
|
||||||
|
<div class="col">
|
||||||
|
<div class="card h-100 bg-dark text-white border-secondary shadow-sm hover-card position-relative">
|
||||||
|
<a href="{% url 'accessory_item_detail' item.pk %}" class="stretched-link"></a>
|
||||||
|
{% if item.image %}
|
||||||
|
<img src="{{ item.image.url }}" class="card-img-top" style="aspect-ratio: 1/1; object-fit: cover;" alt="{{ item.name }}">
|
||||||
|
{% else %}
|
||||||
|
<div class="card-img-top bg-secondary d-flex align-items-center justify-content-center" style="aspect-ratio: 1/1;">
|
||||||
|
<i class="bi bi-gem text-muted" style="font-size: 2rem;"></i>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="card-body p-2">
|
||||||
|
<h6 class="card-title mb-0 text-truncate">{{ item.name|default:"Unnamed" }}</h6>
|
||||||
|
<p class="card-text small text-muted mb-0">{{ item.category.name|default:"Uncategorized" }}</p>
|
||||||
|
{% if item.season %}
|
||||||
|
<span class="badge bg-primary position-absolute top-0 end-0 m-2">{{ item.get_season_display }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<div class="col-12 text-center py-5">
|
||||||
|
<i class="bi bi-inbox text-muted" style="font-size: 3rem;"></i>
|
||||||
|
<p class="mt-2">No accessories found in this category.</p>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.hover-card { transition: transform 0.2s, box-shadow 0.2s; }
|
||||||
|
.hover-card:hover { transform: translateY(-5px); box-shadow: 0 10px 20px rgba(0,0,0,0.5) !important; z-index: 10; }
|
||||||
|
.nav-pills .nav-link { color: #adb5bd; border: 1px solid transparent; margin-right: 5px; }
|
||||||
|
.nav-pills .nav-link.active { background-color: #0d6efd; color: white; }
|
||||||
|
.nav-pills .nav-link:hover:not(.active) { border-color: #495057; }
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
@ -1,145 +1,266 @@
|
|||||||
{% 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">
|
||||||
|
<span class="season-icon {% if season_filter == 'autumn' %}active{% endif %}" title="Autumn" style="{% if season_filter == 'autumn' %}color: #8B4513; opacity: 1;{% endif %}">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 2L10 4L12 6L14 4L12 2Z" />
|
||||||
|
<path d="M12 6V22" stroke="currentColor" stroke-width="2" />
|
||||||
|
<path d="M12 10L8 8" stroke="currentColor" stroke-width="2" />
|
||||||
|
<path d="M12 14L16 12" stroke="currentColor" stroke-width="2" />
|
||||||
|
<path d="M12 18L7 16" stroke="currentColor" stroke-width="2" />
|
||||||
|
<path d="M12 12L17 10" stroke="currentColor" stroke-width="2" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</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">
|
||||||
|
<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>
|
||||||
|
</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-3 mb-3">
|
||||||
|
{% 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">{{ day.day }}</div>
|
||||||
|
<div class="fw-bold {% if day.date == today %}accent-lavender{% endif %}">{{ day.date|date:"d" }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="outfit-preview my-2 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: -5px; margin-right: -5px;">
|
||||||
|
<i class="fas fa-times-circle" style="font-size: 0.8rem;"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<div class="rounded bg-secondary position-relative overflow-hidden" style="height: 60px;">
|
||||||
|
{% with first_item=day.outfit.items.first %}
|
||||||
|
{% if first_item %}
|
||||||
|
<img src="{{ first_item.image.url }}" class="w-100 h-100 object-fit-cover" alt="">
|
||||||
|
{% else %}
|
||||||
|
<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 %}
|
||||||
|
{% endwith %}
|
||||||
|
</div>
|
||||||
|
<div class="small mt-1 text-truncate" 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: 60px; 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: 0.8rem;"></i>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Row 2: Thu, Fri -->
|
||||||
|
<div class="row g-3 mb-3">
|
||||||
|
{% for day in calendar_data|slice:"3:5" %}
|
||||||
|
<div class="col-6">
|
||||||
|
<div class="glass-card p-3 text-center h-100 d-flex flex-column justify-content-between">
|
||||||
|
<div>
|
||||||
|
<div class="small text-dim">{{ day.day }}</div>
|
||||||
|
<div class="fw-bold {% if day.date == today %}accent-lavender{% endif %}">{{ day.date|date:"d" }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="outfit-preview my-2 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: -5px; margin-right: -5px;">
|
||||||
|
<i class="fas fa-times-circle" style="font-size: 0.9rem;"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<div class="rounded bg-secondary position-relative overflow-hidden" style="height: 80px;">
|
||||||
|
{% with first_item=day.outfit.items.first %}
|
||||||
|
{% if first_item %}
|
||||||
|
<img src="{{ first_item.image.url }}" class="w-100 h-100 object-fit-cover" alt="">
|
||||||
|
{% else %}
|
||||||
|
<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 %}
|
||||||
|
{% endwith %}
|
||||||
|
</div>
|
||||||
|
<div class="small mt-1 text-truncate">{{ 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: 80px; 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"></i>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Row 3: Sat, Sun -->
|
||||||
|
<div class="row g-3">
|
||||||
|
{% for day in calendar_data|slice:"5:" %}
|
||||||
|
<div class="col-6">
|
||||||
|
<div class="glass-card p-3 text-center h-100 d-flex flex-column justify-content-between">
|
||||||
|
<div>
|
||||||
|
<div class="small text-dim">{{ day.day }}</div>
|
||||||
|
<div class="fw-bold {% if day.date == today %}accent-lavender{% endif %}">{{ day.date|date:"d" }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="outfit-preview my-2 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: -5px; margin-right: -5px;">
|
||||||
|
<i class="fas fa-times-circle" style="font-size: 0.9rem;"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<div class="rounded bg-secondary position-relative overflow-hidden" style="height: 80px;">
|
||||||
|
{% with first_item=day.outfit.items.first %}
|
||||||
|
{% if first_item %}
|
||||||
|
<img src="{{ first_item.image.url }}" class="w-100 h-100 object-fit-cover" alt="">
|
||||||
|
{% else %}
|
||||||
|
<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 %}
|
||||||
|
{% endwith %}
|
||||||
|
</div>
|
||||||
|
<div class="small mt-1 text-truncate">{{ 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: 80px; 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"></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" style="width: 50px; height: 50px; flex-shrink: 0;">
|
||||||
|
{% with first_item=outfit.items.first %}
|
||||||
|
{% if first_item %}
|
||||||
|
<img src="{{ first_item.image.url }}" class="w-100 h-100 object-fit-cover" alt="">
|
||||||
|
{% else %}
|
||||||
|
<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 %}
|
||||||
|
{% endwith %}
|
||||||
|
</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>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
<style>
|
<style>
|
||||||
:root {
|
.text-dim { color: var(--text-dim); }
|
||||||
--bg-color-start: #6a11cb;
|
.object-fit-cover { object-fit: cover; }
|
||||||
--bg-color-end: #2575fc;
|
.tracking-wider { letter-spacing: 0.05em; }
|
||||||
--text-color: #ffffff;
|
.cursor-pointer { cursor: pointer; }
|
||||||
--card-bg-color: rgba(255, 255, 255, 0.01);
|
.btn-outline-lavender {
|
||||||
--card-border-color: rgba(255, 255, 255, 0.1);
|
color: var(--primary-accent);
|
||||||
}
|
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;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
main {
|
|
||||||
padding: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
background: var(--card-bg-color);
|
|
||||||
border: 1px solid var(--card-border-color);
|
|
||||||
border-radius: 16px;
|
|
||||||
padding: 2.5rem 2rem;
|
|
||||||
backdrop-filter: blur(20px);
|
|
||||||
-webkit-backdrop-filter: blur(20px);
|
|
||||||
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 %}
|
||||||
51
core/templates/core/item_detail.html
Normal file
51
core/templates/core/item_detail.html
Normal 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 %}
|
||||||
92
core/templates/core/item_form.html
Normal file
92
core/templates/core/item_form.html
Normal 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 %}
|
||||||
146
core/templates/core/new_fit.html
Normal file
146
core/templates/core/new_fit.html
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h3 class="mb-0">{{ title }}</h3>
|
||||||
|
<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">
|
||||||
|
{% csrf_token %}
|
||||||
|
|
||||||
|
{% if form.errors %}
|
||||||
|
<div class="alert alert-danger">
|
||||||
|
<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="mb-4">
|
||||||
|
<label class="form-label text-dim small text-uppercase">Outfit Name (Optional)</label>
|
||||||
|
{{ form.name }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="form-label text-dim small text-uppercase">Season (Optional)</label>
|
||||||
|
{{ form.season }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="form-label text-dim small text-uppercase d-flex justify-content-between">
|
||||||
|
Select Items
|
||||||
|
<span class="badge bg-accent text-dark" id="item-count">0 selected</span>
|
||||||
|
</label>
|
||||||
|
<div class="row g-2 overflow-y-auto no-scrollbar" style="max-height: 300px;">
|
||||||
|
{% for item in wardrobe_items %}
|
||||||
|
<div class="col-4">
|
||||||
|
<div class="item-card position-relative rounded overflow-hidden border border-2 border-transparent"
|
||||||
|
data-id="{{ item.id }}" data-type="item" style="cursor: pointer; height: 100px;">
|
||||||
|
<img src="{{ item.image.url }}" class="w-100 h-100 object-fit-cover">
|
||||||
|
<div class="position-absolute bottom-0 start-0 end-0 p-1 bg-dark bg-opacity-75" style="font-size: 0.6rem;">
|
||||||
|
{{ item.name|default:"Item"|truncatechars:10 }}
|
||||||
|
</div>
|
||||||
|
<div class="check-overlay position-absolute top-0 end-0 p-1 d-none">
|
||||||
|
<i class="fas fa-check-circle text-primary-accent"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="d-none">
|
||||||
|
{{ form.items }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="form-label text-dim small text-uppercase">Select Accessories</label>
|
||||||
|
<div class="d-flex gap-2 overflow-x-auto pb-2 no-scrollbar">
|
||||||
|
{% for acc in accessories %}
|
||||||
|
<div class="item-card position-relative rounded-circle overflow-hidden border border-2 border-transparent"
|
||||||
|
data-id="{{ acc.id }}" data-type="accessory" style="cursor: pointer; width: 70px; height: 70px; flex-shrink: 0;">
|
||||||
|
<img src="{{ acc.image.url }}" class="w-100 h-100 object-fit-cover">
|
||||||
|
<div class="check-overlay position-absolute top-0 end-0 p-1 d-none">
|
||||||
|
<i class="fas fa-check-circle text-primary-accent"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="d-none">
|
||||||
|
{{ form.accessories }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="form-label text-dim small text-uppercase">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>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-accent w-100 py-3 fw-bold">SAVE OUTFIT</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
<style>
|
||||||
|
.item-card.selected {
|
||||||
|
border-color: var(--primary-accent) !important;
|
||||||
|
}
|
||||||
|
.item-card.selected .check-overlay {
|
||||||
|
display: block !important;
|
||||||
|
}
|
||||||
|
.border-transparent { border-color: transparent; }
|
||||||
|
.text-primary-accent { color: var(--primary-accent); }
|
||||||
|
.object-fit-cover { object-fit: cover; }
|
||||||
|
.no-scrollbar::-webkit-scrollbar { display: none; }
|
||||||
|
</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');
|
||||||
|
|
||||||
|
function updateBadge() {
|
||||||
|
const selectedCount = document.querySelectorAll('.item-card.selected').length;
|
||||||
|
itemCountBadge.textContent = `${selectedCount} selected`;
|
||||||
|
}
|
||||||
|
|
||||||
|
itemCards.forEach(card => {
|
||||||
|
card.addEventListener('click', function() {
|
||||||
|
const id = this.dataset.id;
|
||||||
|
const type = this.dataset.type;
|
||||||
|
const isSelected = this.classList.toggle('selected');
|
||||||
|
|
||||||
|
const select = (type === 'item') ? itemsSelect : accsSelect;
|
||||||
|
|
||||||
|
for (let option of select.options) {
|
||||||
|
if (option.value === id) {
|
||||||
|
option.selected = isSelected;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateBadge();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initial badge update if some items are selected (e.g. on form error)
|
||||||
|
updateBadge();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
170
core/templates/core/outfit_list.html
Normal file
170
core/templates/core/outfit_list.html
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<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> New Fit
|
||||||
|
</a>
|
||||||
|
</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' %}" 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 }}" 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 %}
|
||||||
|
<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-3">
|
||||||
|
{% for outfit in outfits %}
|
||||||
|
<div class="col-6 col-md-4">
|
||||||
|
<div class="glass-card outfit-card h-100 overflow-hidden position-relative">
|
||||||
|
<!-- Delete Button -->
|
||||||
|
<form action="{% url 'delete_outfit' outfit.pk %}" method="post" class="position-absolute top-0 end-0 z-2 m-1">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="btn btn-sm btn-link text-danger p-0" onclick="return confirm('Delete this outfit?')">
|
||||||
|
<i class="fas fa-times-circle shadow-sm" style="font-size: 1.1rem; background: rgba(0,0,0,0.5); border-radius: 50%;"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="outfit-collage p-2" data-bs-toggle="modal" data-bs-target="#previewModal{{ outfit.id }}">
|
||||||
|
<div class="rounded bg-secondary position-relative overflow-hidden mb-2" style="aspect-ratio: 1/1;">
|
||||||
|
{% with first_item=outfit.items.first %}
|
||||||
|
{% if first_item %}
|
||||||
|
<img src="{{ first_item.image.url }}" class="w-100 h-100 object-fit-cover" alt="">
|
||||||
|
{% else %}
|
||||||
|
<div class="w-100 h-100 d-flex align-items-center justify-content-center bg-dark">
|
||||||
|
<i class="fas fa-tshirt text-dim fa-2x"></i>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
</div>
|
||||||
|
<div class="small fw-bold text-truncate">{{ outfit.name|default:"Unnamed Outfit" }}</div>
|
||||||
|
<div class="small text-dim">{{ outfit.items.count }} items</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick Schedule Button -->
|
||||||
|
<div class="p-2 pt-0">
|
||||||
|
<button class="btn btn-sm btn-outline-secondary w-100 py-0" style="font-size: 0.7rem;" data-bs-toggle="modal" data-bs-target="#scheduleModal{{ outfit.id }}">
|
||||||
|
<i class="far fa-calendar-alt me-1"></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">
|
||||||
|
<div class="modal-content bg-dark text-white border-secondary">
|
||||||
|
<div class="modal-header border-secondary">
|
||||||
|
<h5 class="modal-title">{{ 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="mb-3">
|
||||||
|
<div class="small text-dim mb-2 text-uppercase tracking-wider">Clothing Items</div>
|
||||||
|
<div class="d-flex flex-wrap gap-2">
|
||||||
|
{% for item in outfit.items.all %}
|
||||||
|
<div class="rounded bg-secondary overflow-hidden" style="width: 60px; height: 60px;">
|
||||||
|
<img src="{{ item.image.url }}" class="w-100 h-100 object-fit-cover" title="{{ item.name }}">
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if outfit.accessories.exists %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="small text-dim mb-2 text-uppercase tracking-wider">Accessories</div>
|
||||||
|
<div class="d-flex flex-wrap gap-2">
|
||||||
|
{% for acc in outfit.accessories.all %}
|
||||||
|
<div class="rounded bg-secondary overflow-hidden" style="width: 60px; height: 60px;">
|
||||||
|
<img src="{{ acc.image.url }}" class="w-100 h-100 object-fit-cover" title="{{ acc.name }}">
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="mt-4">
|
||||||
|
<button class="btn btn-lavender w-100" data-bs-toggle="modal" data-bs-target="#scheduleModal{{ outfit.id }}">
|
||||||
|
<i class="far fa-calendar-alt me-2"></i> Schedule for this week
|
||||||
|
</button>
|
||||||
|
</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">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-2">{{ day.date }}</span>
|
||||||
|
</div>
|
||||||
|
<i class="fas fa-calendar-plus text-lavender"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<div class="col-12 text-center py-5">
|
||||||
|
<p class="text-dim">No outfits found.</p>
|
||||||
|
<a href="{% url 'new_fit' %}" class="btn btn-outline-lavender rounded-pill">Create Outfit</a>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</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; }
|
||||||
|
.outfit-card { transition: transform 0.2s; cursor: pointer; }
|
||||||
|
.outfit-card:hover { transform: translateY(-2px); }
|
||||||
|
.object-fit-cover { object-fit: cover; }
|
||||||
|
.z-2 { z-index: 2; }
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
94
core/templates/core/wardrobe_list.html
Normal file
94
core/templates/core/wardrobe_list.html
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid py-4">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h1 class="h2 mb-0">Wardrobe</h1>
|
||||||
|
<a href="{% url 'add_wardrobe_item' %}" class="btn btn-primary shadow-sm">
|
||||||
|
<i class="bi bi-plus-lg"></i> Add Item
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Categories Tabs -->
|
||||||
|
<ul class="nav nav-pills mb-3 overflow-auto flex-nowrap pb-2" id="mainCatTabs" role="tablist">
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<a class="nav-link {% if not current_main %}active{% endif %}" href="{% url 'wardrobe_list' %}">All</a>
|
||||||
|
</li>
|
||||||
|
{% for main_cat in main_categories %}
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<a class="nav-link {% if current_main.id == main_cat.id %}active{% endif %}"
|
||||||
|
href="?main_category={{ main_cat.id }}">{{ main_cat.name }}</a>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<!-- Subcategories Tabs (if main category selected) -->
|
||||||
|
{% if current_main and subcategories %}
|
||||||
|
<div class="mb-4">
|
||||||
|
<div class="d-flex flex-wrap gap-2">
|
||||||
|
<a href="?main_category={{ current_main.id }}"
|
||||||
|
class="btn btn-sm {% if not current_sub %}btn-secondary{% else %}btn-outline-secondary{% endif %} rounded-pill">
|
||||||
|
All {{ current_main.name }}
|
||||||
|
</a>
|
||||||
|
{% for sub in subcategories %}
|
||||||
|
<a href="?main_category={{ current_main.id }}&subcategory={{ sub.id }}"
|
||||||
|
class="btn btn-sm {% if current_sub.id == sub.id %}btn-secondary{% else %}btn-outline-secondary{% endif %} rounded-pill">
|
||||||
|
{{ sub.name }}
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Search Bar -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<form method="get" class="d-flex shadow-sm">
|
||||||
|
{% 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="text" name="q" class="form-control bg-dark text-white border-secondary" placeholder="Search wardrobe..." value="{{ request.GET.q }}">
|
||||||
|
<button class="btn btn-secondary border-secondary" type="submit"><i class="bi bi-search"></i></button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Items Grid -->
|
||||||
|
<div class="row row-cols-2 row-cols-sm-3 row-cols-md-4 row-cols-lg-5 g-4">
|
||||||
|
{% for item in items %}
|
||||||
|
<div class="col">
|
||||||
|
<div class="card h-100 bg-dark text-white border-secondary shadow-sm hover-card position-relative">
|
||||||
|
<a href="{% url 'wardrobe_item_detail' item.pk %}" class="stretched-link"></a>
|
||||||
|
{% if item.image %}
|
||||||
|
<img src="{{ item.image.url }}" class="card-img-top" style="aspect-ratio: 3/4; object-fit: cover;" alt="{{ item.name }}">
|
||||||
|
{% else %}
|
||||||
|
<div class="card-img-top bg-secondary d-flex align-items-center justify-content-center" style="aspect-ratio: 3/4;">
|
||||||
|
<i class="bi bi-image text-muted" style="font-size: 2rem;"></i>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="card-body p-2">
|
||||||
|
<h6 class="card-title mb-0 text-truncate">{{ item.name|default:"Unnamed" }}</h6>
|
||||||
|
<p class="card-text small text-muted mb-0">{{ item.category.name|default:"Uncategorized" }}</p>
|
||||||
|
{% if item.season %}
|
||||||
|
<span class="badge bg-primary position-absolute top-0 end-0 m-2">{{ item.get_season_display }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<div class="col-12 text-center py-5">
|
||||||
|
<i class="bi bi-inbox text-muted" style="font-size: 3rem;"></i>
|
||||||
|
<p class="mt-2">No items found in this category.</p>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.hover-card { transition: transform 0.2s, box-shadow 0.2s; }
|
||||||
|
.hover-card:hover { transform: translateY(-5px); box-shadow: 0 10px 20px rgba(0,0,0,0.5) !important; z-index: 10; }
|
||||||
|
.nav-pills .nav-link { color: #adb5bd; border: 1px solid transparent; margin-right: 5px; }
|
||||||
|
.nav-pills .nav-link.active { background-color: #0d6efd; color: white; }
|
||||||
|
.nav-pills .nav-link:hover:not(.active) { border-color: #495057; }
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
@ -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
|
||||||
19
core/urls.py
19
core/urls.py
@ -1,7 +1,20 @@
|
|||||||
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('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'),
|
||||||
]
|
]
|
||||||
|
|||||||
48
core/utils.py
Normal file
48
core/utils.py
Normal 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
|
||||||
313
core/views.py
313
core/views.py
@ -1,25 +1,298 @@
|
|||||||
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 .models import Category, WardrobeItem, Accessory, Outfit, OutfitFolder, CalendarAssignment
|
||||||
|
from .forms import WardrobeItemForm, AccessoryForm, OutfitForm
|
||||||
|
|
||||||
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')
|
||||||
|
|
||||||
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", ""),
|
|
||||||
}
|
}
|
||||||
return render(request, "core/index.html", context)
|
return render(request, 'core/index.html', context)
|
||||||
|
|
||||||
|
@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')
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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'
|
||||||
|
}
|
||||||
|
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')
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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'
|
||||||
|
}
|
||||||
|
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')
|
||||||
|
|
||||||
|
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)
|
||||||
|
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)]
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'outfits': outfits,
|
||||||
|
'folders': folders,
|
||||||
|
'current_folder': current_folder,
|
||||||
|
'title': 'Outfits',
|
||||||
|
'week_data': week_data,
|
||||||
|
}
|
||||||
|
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()
|
||||||
|
accessories = Accessory.objects.all()
|
||||||
|
|
||||||
|
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,
|
||||||
|
'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')
|
||||||
BIN
media/accessories/images.jpg
Normal file
BIN
media/accessories/images.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
BIN
media/wardrobe/6298b026a65cf80bcf9dce061e9b79c9.png
Normal file
BIN
media/wardrobe/6298b026a65cf80bcf9dce061e9b79c9.png
Normal file
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
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 |
@ -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
|
||||||
Loading…
x
Reference in New Issue
Block a user