nes-num lines-num-new"> 72
+      removeCb.addEventListener('change', function(){
73
+        if (removeCb.checked) {
74
+          if (preview) { preview.classList.add('hidden'); }
75
+          if (placeholder) { placeholder.classList.remove('hidden'); }
76
+          if (input) { input.value = ''; }
77
+        }
78
+      });
79
+    }
80
+  })();
81
+</script>
82
+{% endblock %}

+ 1 - 0
orgs/urls_admin.py

@@ -9,4 +9,5 @@ urlpatterns = [
9 9
     path("permissions/", views.permissions_overview, name="permissions_overview"),
10 10
     path("permissions/<int:pk>/", views.permissions_edit_group, name="permissions_edit_group"),
11 11
     path("permissions/<int:pk>/users/", views.permissions_group_users, name="permissions_group_users"),
12
+    path("me/", views.my_profile, name="my_profile"),
12 13
 ]

+ 37 - 1
orgs/views_admin.py

@@ -10,7 +10,7 @@ from django.core.paginator import Paginator, PageNotAnInteger, EmptyPage
10 10
 from django.db import models
11 11
 
12 12
 from .models import Organization, OrganizationSite, UserProfile
13
-from .forms import OrganizationForm
13
+from .forms import OrganizationForm, UserSelfForm, UserProfilePhotoForm
14 14
 
15 15
 from admin_frontend.templatetags.public_urls import public_route
16 16
 from cms.views import breadcrumbs
@@ -222,3 +222,39 @@ def permissions_group_users(request, pk: int):
222 222
         "orgs/permissions_group_users.html",
223 223
         {"group": group, "users": page_obj.object_list, "page_obj": page_obj, "q": q, "role": role, "role_choices": role_choices},
224 224
     )
225
+
226
+
227
+# ---------------------------------------------------------------------------
228
+# Self profile editing (current authenticated user)
229
+
230
+
231
+@breadcrumbs(label="My Profile", name="my_profile")
232
+def my_profile(request):
233
+    if not request.user.is_authenticated:
234
+        messages.error(request, "Please sign in to manage your profile.")
235
+        return redirect("admin_frontend:login")
236
+
237
+    user = request.user
238
+    user_profile = getattr(user, "recycle_profile", None)
239
+
240
+    if request.method == "POST":
241
+        form_user = UserSelfForm(request.POST, instance=user)
242
+        form_photo = UserProfilePhotoForm(request.POST, request.FILES, instance=user_profile) if user_profile else None
243
+        ok_user = form_user.is_valid()
244
+        ok_photo = True if form_photo is None else form_photo.is_valid()
245
+        if ok_user and ok_photo:
246
+            form_user.save()
247
+            if form_photo is not None:
248
+                form_photo.save()
249
+            messages.success(request, "Profile updated.")
250
+            return redirect(reverse("orgs_admin:my_profile"))
251
+        messages.error(request, "Please correct the errors below.")
252
+    else:
253
+        form_user = UserSelfForm(instance=user)
254
+        form_photo = UserProfilePhotoForm(instance=user_profile) if user_profile else None
255
+
256
+    return render(
257
+        request,
258
+        "orgs/my_profile.html",
259
+        {"form_user": form_user, "form_photo": form_photo, "has_profile": bool(user_profile)},
260
+    )

+ 143 - 0
public_frontend/forms.py

@@ -1,6 +1,12 @@
1 1
 from __future__ import annotations
2 2
 
3 3
 from django import forms
4
+from django.contrib.auth import get_user_model
5
+from django.core.exceptions import ValidationError
6
+from crispy_forms.helper import FormHelper
7
+from crispy_forms.layout import Layout, Field
8
+from django.contrib.auth import get_user_model
9
+from orgs.models import UserProfile
4 10
 
5 11
 
6 12
 class MultiFileInput(forms.ClearableFileInput):
@@ -41,3 +47,140 @@ class ContactForm(forms.Form):
41 47
     phone = forms.CharField(max_length=64, required=False)
42 48
     subject = forms.CharField(max_length=255, required=False)
43 49
     message = forms.CharField(widget=forms.Textarea)
50
+
51
+
52
+class RegistrationForm(forms.Form):
53
+    username = forms.CharField(max_length=150)
54
+    email = forms.EmailField()
55
+    password1 = forms.CharField(widget=forms.PasswordInput)
56
+    password2 = forms.CharField(widget=forms.PasswordInput)
57
+    role = forms.ChoiceField(choices=[(k, v) for k, v in UserProfile.ROLE_CHOICES if k != UserProfile.ROLE_OWNER])
58
+
59
+    def clean_username(self):
60
+        username = self.cleaned_data["username"].strip()
61
+        User = get_user_model()
62
+        if User.objects.filter(username__iexact=username).exists():
63
+            raise forms.ValidationError("Username already taken.")
64
+        return username
65
+
66
+    def clean_email(self):
67
+        email = self.cleaned_data["email"].strip()
68
+        User = get_user_model()
69
+        if User.objects.filter(email__iexact=email).exists():
70
+            raise forms.ValidationError("Email already registered.")
71
+        return email
72
+
73
+    def clean(self):
74
+        data = super().clean()
75
+        p1 = data.get("password1")
76
+        p2 = data.get("password2")
77
+        if p1 and p2 and p1 != p2:
78
+            self.add_error("password2", "Passwords do not match.")
79
+        return data
80
+
81
+
82
+class PublicUserForm(forms.ModelForm):
83
+    class Meta:
84
+        model = get_user_model()
85
+        fields = ["first_name", "last_name", "email"]
86
+
87
+    def __init__(self, *args, **kwargs):
88
+        super().__init__(*args, **kwargs)
89
+        self.helper = FormHelper()
90
+        self.helper.form_tag = False
91
+        self.helper.layout = Layout(
92
+            Field("first_name"),
93
+            Field("last_name"),
94
+            Field("email"),
95
+        )
96
+
97
+
98
+from api.models import Profile as ApiProfile
99
+from orgs.models import UserProfile
100
+
101
+
102
+class PublicProfileForm(forms.ModelForm):
103
+    tags = forms.CharField(label="Tags", required=False, help_text="Comma-separated")
104
+
105
+    class Meta:
106
+        model = ApiProfile
107
+        fields = ["bio", "interests", "industry"]
108
+
109
+    def __init__(self, *args, **kwargs):
110
+        super().__init__(*args, **kwargs)
111
+        self.helper = FormHelper()
112
+        self.helper.form_tag = False
113
+        self.helper.layout = Layout(
114
+            Field("bio"),
115
+            Field("interests"),
116
+            Field("industry"),
117
+            Field("tags"),
118
+        )
119
+        if self.instance and getattr(self.instance, "pk", None):
120
+            try:
121
+                names = list(self.instance.tags.names())
122
+                self.fields["tags"].initial = ", ".join(names)
123
+            except Exception:
124
+                self.fields["tags"].initial = ""
125
+
126
+    def save(self, commit: bool = True):
127
+        profile = super().save(commit)
128
+        tags = [t.strip() for t in self.cleaned_data.get("tags", "").split(",") if t.strip()]
129
+        try:
130
+            if commit and hasattr(profile, "tags"):
131
+                profile.tags.set(tags)
132
+        except Exception:
133
+            pass
134
+        return profile
135
+
136
+
137
+class PublicUserPhotoForm(forms.ModelForm):
138
+    remove_photo = forms.BooleanField(label="Remove current photo", required=False)
139
+
140
+    class Meta:
141
+        model = UserProfile
142
+        fields = ["my_photo", "remove_photo"]
143
+
144
+    def __init__(self, *args, **kwargs):
145
+        super().__init__(*args, **kwargs)
146
+        self.fields["my_photo"].widget = forms.ClearableFileInput(attrs={
147
+            "accept": "image/*",
148
+            "class": "hidden",
149
+            "id": "id_my_photo",
150
+        })
151
+        self.helper = FormHelper()
152
+        self.helper.form_tag = False
153
+        self.helper.layout = Layout(
154
+            Field("my_photo"),
155
+            Field("remove_photo"),
156
+        )
157
+
158
+    def clean_my_photo(self):
159
+        f = self.cleaned_data.get("my_photo")
160
+        if not f:
161
+            return f
162
+        try:
163
+            content_type = getattr(f, "content_type", "")
164
+            if content_type and not content_type.startswith("image/"):
165
+                raise ValidationError("Please upload an image file.")
166
+        except Exception:
167
+            pass
168
+        max_bytes = 5 * 1024 * 1024
169
+        if getattr(f, "size", 0) and f.size > max_bytes:
170
+            raise ValidationError("Image too large (max 5MB).")
171
+        return f
172
+
173
+    def save(self, commit: bool = True):
174
+        instance: UserProfile = super().save(commit=False)
175
+        remove = self.cleaned_data.get("remove_photo", False)
176
+        new_file = self.cleaned_data.get("my_photo")
177
+        if remove and not new_file:
178
+            try:
179
+                if instance.my_photo:
180
+                    instance.my_photo.delete(save=False)
181
+            except Exception:
182
+                pass
183
+            instance.my_photo = None
184
+        if commit:
185
+            instance.save()
186
+        return instance

+ 23 - 13
public_frontend/templates/public_frontend/base.html

@@ -20,19 +20,29 @@
20 20
         <a href="/#blog" class="hover:text-black/70">{% trans "Blog" %}</a>
21 21
         <a href="/#contact" class="hover:text-black/70">{% trans "Contact" %}</a>
22 22
       </nav>
23
-      <form method="post" action="{% url 'set_language' %}" class="hidden md:block ml-4">
24
-        {% csrf_token %}
25
-        <input type="hidden" name="next" value="{{ request.get_full_path }}" />
26
-        {% get_available_languages as langs %}
27
-        {% get_language_info_list for langs as languages %}
28
-        <label for="lang" class="sr-only">Language</label>
29
-        <select id="lang" name="language" class="text-sm border border-gray-300 rounded px-2 py-1 bg-white"
30
-                onchange="this.form.submit()">
31
-          {% for lang in languages %}
32
-            <option value="{{ lang.code }}" {% if lang.code == LANGUAGE_CODE %}selected{% endif %}>{{ lang.name_local }}</option>
33
-          {% endfor %}
34
-        </select>
35
-      </form>
23
+      <div class="hidden md:flex items-center gap-4 ml-4">
24
+        {% if request.user.is_authenticated %}
25
+          <span class="text-sm text-gray-600">{{ request.user.username }}</span>
26
+          <a class="text-sm text-blue-600 hover:text-blue-700" href="{% url 'public_frontend:my_profile' %}">{% trans "My Profile" %}</a>
27
+          <a class="text-sm text-blue-600 hover:text-blue-700" href="{% url 'public_frontend:logout' %}">{% trans "Logout" %}</a>
28
+        {% else %}
29
+          <a class="text-sm text-blue-600 hover:text-blue-700" href="{% url 'public_frontend:login' %}">{% trans "Login" %}</a>
30
+          <a class="text-sm text-blue-600 hover:text-blue-700" href="{% url 'public_frontend:register' %}">{% trans "Register" %}</a>
31
+        {% endif %}
32
+        <form method="post" action="{% url 'set_language' %}">
33
+          {% csrf_token %}
34
+          <input type="hidden" name="next" value="{{ request.get_full_path }}" />
35
+          {% get_available_languages as langs %}
36
+          {% get_language_info_list for langs as languages %}
37
+          <label for="lang" class="sr-only">Language</label>
38
+          <select id="lang" name="language" class="text-sm border border-gray-300 rounded px-2 py-1 bg-white"
39
+                  onchange="this.form.submit()">
40
+            {% for lang in languages %}
41
+              <option value="{{ lang.code }}" {% if lang.code == LANGUAGE_CODE %}selected{% endif %}>{{ lang.name_local }}</option>
42
+            {% endfor %}
43
+          </select>
44
+        </form>
45
+      </div>
36 46
     </div>
37 47
   </header>
38 48
   <main class="mx-auto max-w-7xl p-4 sm:p-6 lg:p-8 min-h-[80vh]">

+ 36 - 0
public_frontend/templates/public_frontend/login.html

@@ -0,0 +1,36 @@
1
+{% extends 'public_frontend/base.html' %}
2
+{% load i18n %}
3
+{% block title %}{% trans "Login" %}{% endblock %}
4
+
5
+{% block content %}
6
+<div class="max-w-md mx-auto">
7
+  <h1 class="text-2xl font-semibold mb-4">{% trans "Login" %}</h1>
8
+  <div class="bg-white rounded shadow p-4">
9
+    <form method="post" action="" class="grid gap-4">
10
+      {% csrf_token %}
11
+      {% if form.non_field_errors %}
12
+        <div class="text-sm text-rose-700">{{ form.non_field_errors }}</div>
13
+      {% endif %}
14
+      <div>
15
+        <label class="block text-sm font-medium mb-1" for="id_username">{% trans "Username" %}</label>
16
+        <input type="text" name="username" id="id_username" value="{{ form.username.value|default:'' }}" required class="w-full border rounded px-3 py-2">
17
+        {% if form.username.errors %}<div class="text-sm text-rose-700 mt-1">{{ form.username.errors|join:', ' }}</div>{% endif %}
18
+      </div>
19
+      <div>
20
+        <label class="block text-sm font-medium mb-1" for="id_password">{% trans "Password" %}</label>
21
+        <input type="password" name="password" id="id_password" required class="w-full border rounded px-3 py-2">
22
+        {% if form.password.errors %}<div class="text-sm text-rose-700 mt-1">{{ form.password.errors|join:', ' }}</div>{% endif %}
23
+      </div>
24
+      {% if next %}<input type="hidden" name="next" value="{{ next }}" />{% endif %}
25
+      <div>
26
+        <button class="btn-primary" type="submit">{% trans "Sign In" %}</button>
27
+      </div>
28
+    </form>
29
+    <p class="text-sm text-gray-600 mt-3">{% trans "Don't have an account?" %} <a href="{% url 'public_frontend:register' %}" class="text-blue-700 hover:underline">{% trans "Register" %}</a></p>
30
+  </div>
31
+</div>
32
+<style>
33
+  .btn-primary{background:#1d4ed8;color:#fff;padding:.5rem .75rem;border-radius:.375rem}
34
+</style>
35
+{% endblock %}
36
+

+ 89 - 0
public_frontend/templates/public_frontend/my_profile.html

@@ -0,0 +1,89 @@
1
+{% extends 'public_frontend/base.html' %}
2
+{% load crispy_forms_tags i18n %}
3
+{% block title %}{% trans "My Profile" %}{% endblock %}
4
+{% block content %}
5
+<div class="max-w-3xl mx-auto">
6
+  <h1 class="text-2xl font-semibold mb-4">{% trans "My Profile" %}</h1>
7
+  <form method="post" enctype="multipart/form-data" class="bg-white rounded shadow p-4">
8
+    {% csrf_token %}
9
+    <div class="grid grid-cols-1 gap-6">
10
+      <div>
11
+        <h2 class="text-base font-semibold mb-2">{% trans "Account" %}</h2>
12
+        <div class="grid md:grid-cols-2 gap-4 mb-2">
13
+          <div>
14
+            <label class="block text-sm font-medium mb-1">{% trans "Username" %}</label>
15
+            <input type="text" value="{{ username_value }}" class="w-full border rounded px-3 py-2 bg-gray-50" disabled>
16
+          </div>
17
+          <div>
18
+            <label class="block text-sm font-medium mb-1">{% trans "Role" %}</label>
19
+            <input type="text" value="{{ role_label }}" class="w-full border rounded px-3 py-2 bg-gray-50" disabled>
20
+          </div>
21
+        </div>
22
+        {{ form_user|crispy }}
23
+      </div>
24
+      {% if has_profile %}
25
+      <div>
26
+        <h2 class="text-base font-semibold mb-2">{% trans "Profile Photo" %}</h2>
27
+        <div class="flex items-start gap-4">
28
+          <div>
29
+            <img id="my-photo-preview" src="{% if request.user.recycle_profile.my_photo %}{{ request.user.recycle_profile.my_photo.url }}{% else %}#{% endif %}" alt="Current photo" class="w-24 h-24 rounded object-cover border {% if not request.user.recycle_profile.my_photo %}hidden{% endif %}" />
30
+            {% if not request.user.recycle_profile.my_photo %}
31
+              <div id="my-photo-placeholder" class="w-24 h-24 rounded bg-gray-100 flex items-center justify-center text-gray-400 border">No photo</div>
32
+            {% endif %}
33
+          </div>
34
+          <div class="flex-1">
35
+            {{ form_photo.my_photo }}
36
+            <div class="flex items-center gap-3">
37
+              <button type="button" id="btn-pick-photo" class="btn-outline">Change photo</button>
38
+              {% if form_photo.remove_photo %}
39
+                <label class="inline-flex items-center gap-2 text-sm text-gray-700">
40
+                  {{ form_photo.remove_photo }} Remove current photo
41
+                </label>
42
+              {% endif %}
43
+            </div>
44
+            <p class="text-xs text-gray-500 mt-1">JPG/PNG, up to 5MB.</p>
45
+            <style>.btn-outline{border:1px solid #cbd5e1;padding:.375rem .5rem;border-radius:.375rem}</style>
46
+          </div>
47
+        </div>
48
+      </div>
49
+      {% endif %}
50
+    </div>
51
+    <div class="mt-4">
52
+      <button class="btn-primary" type="submit">{% trans "Save Changes" %}</button>
53
+      <style>.btn-primary{background:#1d4ed8;color:#fff;padding:.5rem .75rem;border-radius:.375rem}</style>
54
+    </div>
55
+  </form>
56
+</div>
57
+<script>
58
+  (function(){
59
+    const input = document.getElementById('id_my_photo');
60
+    const btn = document.getElementById('btn-pick-photo');
61
+    const preview = document.getElementById('my-photo-preview');
62
+    const placeholder = document.getElementById('my-photo-placeholder');
63
+    const removeCb = document.getElementById('id_remove_photo');
64
+    if (btn && input) {
65
+      btn.addEventListener('click', function(){ input.click(); });
66
+    }
67
+    if (input) {
68
+      input.addEventListener('change', function(){
69
+        if (input.files && input.files[0]) {
70
+          const file = input.files[0];
71
+          const url = URL.createObjectURL(file);
72
+          if (preview) { preview.src = url; preview.classList.remove('hidden'); }
73
+          if (placeholder) { placeholder.classList.add('hidden'); }
74
+          if (removeCb) { removeCb.checked = false; }
75
+        }
76
+      });
77
+    }
78
+    if (removeCb) {
79
+      removeCb.addEventListener('change', function(){
80
+        if (removeCb.checked) {
81
+          if (preview) { preview.classList.add('hidden'); }
82
+          if (placeholder) { placeholder.classList.remove('hidden'); }
83
+          if (input) { input.value = ''; }
84
+        }
85
+      });
86
+    }
87
+  })();
88
+</script>
89
+{% endblock %}

+ 41 - 3
public_frontend/templates/public_frontend/pickup_request.html

@@ -4,7 +4,7 @@
4 4
 {% load crispy_forms_tags widget_tweaks %}
5 5
 {% block content %}
6 6
 <h1 class="text-xl font-semibold mb-4">{% trans "Request a Pickup" %}</h1>
7
-<div x-data="pickupForm()">
7
+<div x-data="pickupForm()" x-init="setupPreview()">
8 8
   <div class="bg-white rounded shadow p-4" >
9 9
     <form x-ref="form" id="pickup-form" method="post" enctype="multipart/form-data" class="space-y-4">
10 10
       {% csrf_token %}
@@ -24,7 +24,8 @@
24 24
         </div>
25 25
       </div>
26 26
       {{ form.materials|attr:"x-ref:materials"|as_crispy_field }}
27
-      {{ form.photos|attr:"x-ref:photos"|as_crispy_field }}
27
+      {{ form.photos|attr:"x-ref:photos"|attr:"accept:image/*"|as_crispy_field }}
28
+      <div x-ref="photos_preview" id="photos-preview" class="mt-2 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2"></div>
28 29
       <div>
29 30
         <button id="open-review" class="btn btn-primary" type="button" @click="openReview">Review &amp; Submit</button>
30 31
       </div>
@@ -67,6 +68,43 @@
67 68
   function pickupForm(){
68 69
     return {
69 70
       rv: {name:'', email:'', phone:'', address:'', preferred_at:'', materials:'', photos: []},
71
+      setupPreview(){
72
+        const input = this.$refs.photos;
73
+        const render = () => this.renderPreviews(input && input.files ? Array.from(input.files) : []);
74
+        if (input) {
75
+          input.addEventListener('change', render);
76
+          // Initial render (in case the browser retained selection)
77
+          render();
78
+        }
79
+      },
80
+      renderPreviews(files){
81
+        const wrap = this.$refs.photos_preview;
82
+        if (!wrap) return;
83
+        // Clear existing
84
+        while (wrap.firstChild) wrap.removeChild(wrap.firstChild);
85
+        if (!files || files.length === 0) return;
86
+        files.forEach(f => {
87
+          const item = document.createElement('div');
88
+          item.className = 'relative border rounded overflow-hidden bg-white shadow-sm';
89
+          if (f.type && f.type.startsWith('image/')) {
90
+            const img = document.createElement('img');
91
+            img.src = URL.createObjectURL(f);
92
+            img.alt = f.name;
93
+            img.className = 'w-full h-24 object-cover';
94
+            item.appendChild(img);
95
+          } else {
96
+            const box = document.createElement('div');
97
+            box.className = 'h-24 flex items-center justify-center text-xs text-gray-600 p-2 text-center';
98
+            box.textContent = f.name;
99
+            item.appendChild(box);
100
+          }
101
+          const cap = document.createElement('div');
102
+          cap.className = 'absolute bottom-0 left-0 right-0 bg-black/50 text-white text-[10px] px-1 py-0.5 truncate';
103
+          cap.textContent = f.name;
104
+          item.appendChild(cap);
105
+          wrap.appendChild(item);
106
+        });
107
+      },
70 108
       openReview(){
71 109
         console.log(this.$refs.form)
72 110
         const fd = new FormData(this.$refs.form);
@@ -86,7 +124,7 @@
86 124
         if(this.$refs.dialog.close){ this.$refs.dialog.close(); } else { this.$refs.dialog.removeAttribute('open'); }
87 125
         this.$refs.form.submit();
88 126
       }
89
-    }
90 127
   }
128
+}
91 129
 </script>
92 130
 {% endblock %}

+ 50 - 0
public_frontend/templates/public_frontend/register.html

@@ -0,0 +1,50 @@
1
+{% extends 'public_frontend/base.html' %}
2
+{% load i18n %}
3
+{% block title %}{% trans "Register" %}{% endblock %}
4
+{% block content %}
5
+<div class="max-w-xl mx-auto">
6
+  <h1 class="text-2xl font-semibold mb-4">{% trans "Create an Account" %}</h1>
7
+  <div class="bg-white rounded shadow p-4">
8
+    <form method="post" action="" class="grid gap-4">
9
+      {% csrf_token %}
10
+      <div>
11
+        <label class="block text-sm font-medium mb-1" for="id_username">{% trans "Username" %}</label>
12
+        <input type="text" name="username" id="id_username" value="{{ form.username.value|default:'' }}" required class="w-full border rounded px-3 py-2">
13
+        {% if form.username.errors %}<div class="text-sm text-rose-700 mt-1">{{ form.username.errors|join:", " }}</div>{% endif %}
14
+      </div>
15
+      <div>
16
+        <label class="block text-sm font-medium mb-1" for="id_email">{% trans "Email" %}</label>
17
+        <input type="email" name="email" id="id_email" value="{{ form.email.value|default:'' }}" required class="w-full border rounded px-3 py-2">
18
+        {% if form.email.errors %}<div class="text-sm text-rose-700 mt-1">{{ form.email.errors|join:", " }}</div>{% endif %}
19
+      </div>
20
+      <div class="grid md:grid-cols-2 gap-4">
21
+        <div>
22
+          <label class="block text-sm font-medium mb-1" for="id_password1">{% trans "Password" %}</label>
23
+          <input type="password" name="password1" id="id_password1" required class="w-full border rounded px-3 py-2">
24
+        </div>
25
+        <div>
26
+          <label class="block text-sm font-medium mb-1" for="id_password2">{% trans "Confirm Password" %}</label>
27
+          <input type="password" name="password2" id="id_password2" required class="w-full border rounded px-3 py-2">
28
+          {% if form.password2.errors %}<div class="text-sm text-rose-700 mt-1">{{ form.password2.errors|join:", " }}</div>{% endif %}
29
+        </div>
30
+      </div>
31
+      <div>
32
+        <label class="block text-sm font-medium mb-1" for="id_role">{% trans "Role" %}</label>
33
+        <select name="role" id="id_role" class="w-full border rounded px-3 py-2">
34
+          {% for val,label in form.fields.role.choices %}
35
+            <option value="{{ val }}" {% if form.role.value == val %}selected{% endif %}>{{ label }}</option>
36
+          {% endfor %}
37
+        </select>
38
+      </div>
39
+      <div>
40
+        <button class="btn-primary" type="submit">{% trans "Register" %}</button>
41
+      </div>
42
+    </form>
43
+    <p class="text-sm text-gray-600 mt-3">{% trans "Already have an account?" %} <a href="/accounts/login/" class="text-blue-700 hover:underline">{% trans "Login" %}</a></p>
44
+  </div>
45
+</div>
46
+<style>
47
+  .btn-primary{background:#1d4ed8;color:#fff;padding:.5rem .75rem;border-radius:.375rem}
48
+</style>
49
+{% endblock %}
50
+

+ 6 - 0
public_frontend/urls.py

@@ -1,10 +1,16 @@
1 1
 from django.urls import path
2 2
 from . import views
3
+from django.contrib.auth import views as auth_views
4
+from .views import PublicLoginView
3 5
 
4 6
 app_name = "public_frontend"
5 7
 
6 8
 urlpatterns = [
7 9
     path("", views.home, name="home"),
10
+    path("login/", PublicLoginView.as_view(), name="login"),
11
+    path("logout/", auth_views.LogoutView.as_view(next_page="public_frontend:home"), name="logout"),
12
+    path("me/", views.my_profile, name="my_profile"),
13
+    path("register/", views.register, name="register"),
8 14
     path("services/<int:pk>/", views.service_detail, name="service_detail"),
9 15
     path("materials/", views.materials_list, name="materials_list"),
10 16
     path("listings/", views.listings_list, name="listings_list"),

+ 83 - 2
public_frontend/views.py

@@ -5,18 +5,21 @@ from django.contrib import messages
5 5
 from django.urls import reverse
6 6
 from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger
7 7
 
8
-from orgs.models import Organization
8
+from orgs.models import Organization, UserProfile
9 9
 from recycle_core.models import ProvidedService
10 10
 from recycle_core.models import Material, ScrapListing, PickupOrder
11 11
 from cms.models import Post, PostCategory
12 12
 
13
-from .forms import PickupRequestForm, ContactForm
13
+from .forms import PickupRequestForm, ContactForm, RegistrationForm, PublicUserForm, PublicUserPhotoForm
14 14
 from .models import Lead
15 15
 from recycle_core.controllers.pickup_request import (
16 16
     PickupRequestController,
17 17
     PickupRequestData,
18 18
 )
19 19
 
20
+from django.contrib.auth.views import LoginView
21
+from django.urls import reverse
22
+
20 23
 
21 24
 def home(request):
22 25
     org = getattr(request, "org", None)
@@ -42,6 +45,7 @@ def home(request):
42 45
 
43 46
     pickup_form = PickupRequestForm()
44 47
     contact_form = ContactForm()
48
+    register_form = RegistrationForm()
45 49
 
46 50
     return render(
47 51
         request,
@@ -54,6 +58,7 @@ def home(request):
54 58
             "pickup_form": pickup_form,
55 59
             "contact_form": contact_form,
56 60
             "org": org,
61
+            "register_form": register_form,
57 62
         },
58 63
     )
59 64
 
@@ -153,6 +158,41 @@ def contact(request):
153 158
     return render(request, "public_frontend/contact.html", {"form": form, "org": org})
154 159
 
155 160
 
161
+def register(request):
162
+    from django.contrib.auth import get_user_model
163
+    from django.contrib.auth.models import Group
164
+    from orgs.models import UserProfile
165
+
166
+    org = getattr(request, "org", None)
167
+    if not org:
168
+        messages.error(request, "Organization context missing.")
169
+        return redirect("public_frontend:home")
170
+
171
+    form = RegistrationForm(request.POST or None)
172
+    if request.method == "POST":
173
+        if form.is_valid():
174
+            User = get_user_model()
175
+            user = User(username=form.cleaned_data["username"], email=form.cleaned_data["email"], is_active=False)
176
+            user.set_password(form.cleaned_data["password1"])
177
+            user.save()
178
+            role = form.cleaned_data["role"]
179
+            # Create org profile
180
+            UserProfile.objects.create(user=user, organization=org, role=role)
181
+            # Add to group matching the role if exists
182
+            try:
183
+                g = Group.objects.filter(name=role).first()
184
+                if g:
185
+                    user.groups.add(g)
186
+            except Exception:
187
+                pass
188
+            messages.success(request, "Account created. Please wait for admin approval.")
189
+            return redirect("public_frontend:home")
190
+        else:
191
+            messages.error(request, "Please correct the errors below.")
192
+
193
+    return render(request, "public_frontend/register.html", {"form": form, "org": org})
194
+
195
+
156 196
 def blog_list(request):
157 197
     org = getattr(request, "org", None)
158 198
     qs = Post.objects.all().order_by("-published_at", "-created_at")
@@ -178,3 +218,44 @@ def blog_detail(request, slug: str):
178 218
     # Ensure markdown is rendered; fallback to raw content if needed
179 219
     html = getattr(post, "content_html", None) or getattr(post, "content", "")
180 220
     return render(request, "public_frontend/blog_detail.html", {"post": post, "html": html, "org": org})
221
+
222
+
223
+class PublicLoginView(LoginView):
224
+    template_name = "public_frontend/login.html"
225
+    redirect_authenticated_user = True
226
+
227
+    def get_success_url(self):
228
+        return reverse("public_frontend:home")
229
+
230
+
231
+from django.contrib.auth.decorators import login_required
232
+from django.contrib.auth import get_user_model
233
+
234
+
235
+@login_required
236
+def my_profile(request):
237
+    user = request.user
238
+    profile = getattr(user, "recycle_profile", None)
239
+    role_code = getattr(profile, "role", None)
240
+    role_label = dict(UserProfile.ROLE_CHOICES).get(role_code, "-") if role_code else "-"
241
+    if request.method == "POST":
242
+        form_user = PublicUserForm(request.POST, instance=user)
243
+        form_photo = PublicUserPhotoForm(request.POST, request.FILES, instance=profile) if profile else None
244
+        ok_user = form_user.is_valid()
245
+        ok_photo = True if form_photo is None else form_photo.is_valid()
246
+        if ok_user and ok_photo:
247
+            form_user.save()
248
+            if form_photo is not None:
249
+                form_photo.save()
250
+            messages.success(request, "Profile updated.")
251
+            return redirect(reverse("public_frontend:my_profile"))
252
+        messages.error(request, "Please correct the errors below.")
253
+    else:
254
+        form_user = PublicUserForm(instance=user)
255
+        form_photo = PublicUserPhotoForm(instance=profile) if profile else None
256
+
257
+    return render(
258
+        request,
259
+        "public_frontend/my_profile.html",
260
+        {"form_user": form_user, "form_photo": form_photo, "has_profile": bool(profile), "username_value": user.username, "role_label": role_label},
261
+    )

+ 44 - 0
recycle_core/forms.py

@@ -171,6 +171,7 @@ class UserEditForm(forms.Form):
171 171
     first_name = forms.CharField(max_length=150, required=False)
172 172
     last_name = forms.CharField(max_length=150, required=False)
173 173
     role = forms.ChoiceField(choices=UserProfile.ROLE_CHOICES)
174
+    is_active = forms.BooleanField(required=False, label="Active")
174 175
     password1 = forms.CharField(widget=forms.PasswordInput, required=False)
175 176
     password2 = forms.CharField(widget=forms.PasswordInput, required=False)
176 177
 
@@ -183,6 +184,49 @@ class UserEditForm(forms.Form):
183 184
         return cleaned
184 185
 
185 186
 
187
+class UserProfilePhotoForm(forms.ModelForm):
188
+    remove_photo = forms.BooleanField(label="Remove current photo", required=False)
189
+
190
+    class Meta:
191
+        model = UserProfile
192
+        fields = ["my_photo", "remove_photo"]
193
+
194
+    def __init__(self, *args, **kwargs):
195
+        super().__init__(*args, **kwargs)
196
+        self.fields["my_photo"].widget = forms.ClearableFileInput(attrs={
197
+            "accept": "image/*",
198
+            "class": "hidden",
199
+            "id": "id_my_photo",
200
+        })
201
+
202
+    def clean_my_photo(self):
203
+        f = self.cleaned_data.get("my_photo")
204
+        if not f:
205
+            return f
206
+        content_type = getattr(f, "content_type", "")
207
+        if content_type and not content_type.startswith("image/"):
208
+            raise ValidationError("Please upload an image file.")
209
+        max_bytes = 5 * 1024 * 1024
210
+        if getattr(f, "size", 0) and f.size > max_bytes:
211
+            raise ValidationError("Image too large (max 5MB).")
212
+        return f
213
+
214
+    def save(self, commit: bool = True):
215
+        instance: UserProfile = super().save(commit=False)
216
+        remove = self.cleaned_data.get("remove_photo", False)
217
+        new_file = self.cleaned_data.get("my_photo")
218
+        if remove and not new_file:
219
+            try:
220
+                if instance.my_photo:
221
+                    instance.my_photo.delete(save=False)
222
+            except Exception:
223
+                pass
224
+            instance.my_photo = None
225
+        if commit:
226
+            instance.save()
227
+        return instance
228
+
229
+
186 230
 # Weighing -------------------------------------------------------------------
187 231
 
188 232
 

+ 84 - 2
recycle_core/templates/recycle_core/user_form.html

@@ -8,8 +8,7 @@
8 8
   <a href="{{ cancel_url }}" class="btn-outline">Back</a>
9 9
   <style>.btn-outline{border:1px solid #cbd5e1;padding:0.375rem 0.5rem;border-radius:0.375rem}</style>
10 10
 </div>
11
-
12
-<form method="post" class="bg-white rounded shadow p-4 space-y-4">
11
+<form method="post" enctype="multipart/form-data" class="bg-white rounded shadow p-4 space-y-4">
13 12
   {% csrf_token %}
14 13
   <input type="hidden" name="next" value="{{ request.get_full_path }}">
15 14
   {% if creating %}
@@ -20,6 +19,18 @@
20 19
       <div>{% if form.last_name %}{{ form.last_name|as_crispy_field }}{% endif %}</div>
21 20
     </div>
22 21
     {% if form.role %}{{ form.role|as_crispy_field }}{% endif %}
22
+    {% if form.is_active %}
23
+      <div class="mt-2">
24
+        <div class="flex items-center gap-2">
25
+          {{ form.is_active }}
26
+          <label for="{{ form.is_active.id_for_label }}" class="text-sm font-medium">Active</label>
27
+        </div>
28
+        {% if form.is_active.errors %}
29
+          <div class="text-sm text-rose-700 mt-1">{{ form.is_active.errors|join:", " }}</div>
30
+        {% endif %}
31
+      </div>
32
+    {% endif %}
33
+    
23 34
     <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
24 35
       <div>{% if form.password1 %}{{ form.password1|as_crispy_field }}{% endif %}</div>
25 36
       <div>{% if form.password2 %}{{ form.password2|as_crispy_field }}{% endif %}</div>
@@ -35,6 +46,45 @@
35 46
       <div>{% if form.last_name %}{{ form.last_name|as_crispy_field }}{% endif %}</div>
36 47
     </div>
37 48
     {% if form.role %}{{ form.role|as_crispy_field }}{% endif %}
49
+    {% if form.is_active %}
50
+      <div class="mt-2">
51
+        <div class="flex items-center gap-2">
52
+          {{ form.is_active }}
53
+          <label for="{{ form.is_active.id_for_label }}" class="text-sm font-medium">Active</label>
54
+        </div>
55
+        {% if form.is_active.errors %}
56
+          <div class="text-sm text-rose-700 mt-1">{{ form.is_active.errors|join:", " }}</div>
57
+        {% endif %}
58
+      </div>
59
+    {% endif %}
60
+    {% if form_photo %}
61
+    <div class="mt-4">
62
+      <h3 class="text-sm font-semibold mb-2">Profile Photo</h3>
63
+      <div class="flex items-start gap-4">
64
+        <div>
65
+          {% with photo=item.recycle_profile.my_photo %}
66
+            <img id="my-photo-preview" src="{% if photo %}{{ photo.url }}{% else %}#{% endif %}" alt="Current photo" class="w-24 h-24 rounded object-cover border {% if not photo %}hidden{% endif %}" />
67
+            {% if not photo %}
68
+              <div id="my-photo-placeholder" class="w-24 h-24 rounded bg-gray-100 flex items-center justify-center text-gray-400 border">No photo</div>
69
+            {% endif %}
70
+          {% endwith %}
71
+        </div>
72
+        <div class="flex-1">
73
+          {{ form_photo.my_photo }}
74
+          <div class="flex items-center gap-3">
75
+            <button type="button" id="btn-pick-photo" class="btn-outline">Change photo</button>
76
+            {% if form_photo.remove_photo %}
77
+              <label class="inline-flex items-center gap-2 text-sm text-gray-700">
78
+                {{ form_photo.remove_photo }} Remove current photo
79
+              </label>
80
+            {% endif %}
81
+          </div>
82
+          <p class="text-xs text-gray-500 mt-1">JPG/PNG, up to 5MB.</p>
83
+          <style>.btn-outline{border:1px solid #cbd5e1;padding:.375rem .5rem;border-radius:.375rem}</style>
84
+        </div>
85
+      </div>
86
+    </div>
87
+    {% endif %}
38 88
     <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
39 89
       <div>{% if form.password1 %}{{ form.password1|as_crispy_field }}{% endif %}</div>
40 90
       <div>{% if form.password2 %}{{ form.password2|as_crispy_field }}{% endif %}</div>
@@ -46,4 +96,36 @@
46 96
     <style>.btn{background:#1d4ed8;color:#fff;padding:0.5rem 0.75rem;border-radius:0.375rem}</style>
47 97
   </div>
48 98
 </form>
99
+<script>
100
+  (function(){
101
+    const input = document.getElementById('id_my_photo');
102
+    const btn = document.getElementById('btn-pick-photo');
103
+    const preview = document.getElementById('my-photo-preview');
104
+    const placeholder = document.getElementById('my-photo-placeholder');
105
+    const removeCb = document.getElementById('id_remove_photo');
106
+    if (btn && input) {
107
+      btn.addEventListener('click', function(){ input.click(); });
108
+    }
109
+    if (input) {
110
+      input.addEventListener('change', function(){
111
+        if (input.files && input.files[0]) {
112
+          const file = input.files[0];
113
+          const url = URL.createObjectURL(file);
114
+          if (preview) { preview.src = url; preview.classList.remove('hidden'); }
115
+          if (placeholder) { placeholder.classList.add('hidden'); }
116
+          if (removeCb) { removeCb.checked = false; }
117
+        }
118
+      });
119
+    }
120
+    if (removeCb) {
121
+      removeCb.addEventListener('change', function(){
122
+        if (removeCb.checked) {
123
+          if (preview) { preview.classList.add('hidden'); }
124
+          if (placeholder) { placeholder.classList.remove('hidden'); }
125
+          if (input) { input.value = ''; }
126
+        }
127
+      });
128
+    }
129
+  })();
130
+</script>
49 131
 {% endblock %}

+ 17 - 3
recycle_core/views.py

@@ -153,7 +153,7 @@ def materials_list(request):
153 153
 
154 154
 
155 155
 # Organization Users (owner-only)
156
-from .forms import UserCreateForm, UserEditForm
156
+from .forms import UserCreateForm, UserEditForm, UserProfilePhotoForm
157 157
 
158 158
 
159 159
 @owner_required
@@ -223,12 +223,22 @@ def org_user_edit(request, pk: int):
223 223
         "first_name": user.first_name,
224 224
         "last_name": user.last_name,
225 225
         "role": getattr(profile, "role", UserProfile.ROLE_MANAGER),
226
+        "is_active": user.is_active,
226 227
     })
228
+    form_photo = UserProfilePhotoForm(request.POST or None, request.FILES or None, instance=profile) if profile else None
229
+    print(form_photo)
227 230
     if request.method == "POST":
228
-        if form.is_valid():
231
+        ok_main = form.is_valid()
232
+        ok_photo = True if form_photo is None else form_photo.is_valid()
233
+        if ok_main and ok_photo:
229 234
             user.email = form.cleaned_data.get("email") or ""
230 235
             user.first_name = form.cleaned_data.get("first_name") or ""
231 236
             user.last_name = form.cleaned_data.get("last_name") or ""
237
+            desired_active = bool(form.cleaned_data.get("is_active"))
238
+            if user.id == request.user.id and not desired_active:
239
+                messages.error(request, "You cannot deactivate your own account.")
240
+            else:
241
+                user.is_active = desired_active
232 242
             p1 = form.cleaned_data.get("password1")
233 243
             if p1:
234 244
                 user.set_password(p1)
@@ -239,13 +249,17 @@ def org_user_edit(request, pk: int):
239 249
             if org is not None:
240 250
                 profile.organization = org
241 251
             profile.save()
252
+            if form_photo is not None:
253
+                form_photo.instance = profile
254
+                form_photo.save()
242 255
             messages.success(request, f"User '{user.username}' updated.")
243 256
             next_url = request.POST.get("next") or request.GET.get("next")
244 257
             if next_url:
245 258
                 return redirect(next_url)
246 259
             return redirect("recycle_core:org_users_list")
247 260
         messages.error(request, "Please correct the errors below.")
248
-    return render(request, "recycle_core/user_form.html", {"nav": _nav_items(), "form": form, "item": user, "creating": False, "cancel_url": reverse("recycle_core:org_users_list")})
261
+    print(f"fp = {form_photo}")
262
+    return render(request, "recycle_core/user_form.html", {"nav": _nav_items(), "form": form, "form_photo": form_photo, "item": user, "creating": False, "cancel_url": reverse("recycle_core:org_users_list")})
249 263
 
250 264
 
251 265
 @owner_required

louise/louise-tna - Gogs: Simplico Git Service

No Description

Thiranon Wiriyanurakkul 86d81d25b9 แก้หน้า index_page.html และ menu.html 8 years ago
..
after_prepare 86d81d25b9 แก้หน้า index_page.html และ menu.html 8 years ago
README.md 86d81d25b9 แก้หน้า index_page.html และ menu.html 8 years ago

README.md

Cordova Hooks

This directory may contain scripts used to customize cordova commands. This directory used to exist at .cordova/hooks, but has now been moved to the project root. Any scripts you add to these directories will be executed before and after the commands corresponding to the directory name. Useful for integrating your own build systems or integrating with version control systems.

Remember: Make your scripts executable.

Hook Directories

The following subdirectories will be used for hooks:

after_build/
after_compile/
after_docs/
after_emulate/
after_platform_add/
after_platform_rm/
after_platform_ls/
after_plugin_add/
after_plugin_ls/
after_plugin_rm/
after_plugin_search/
after_prepare/
after_run/
after_serve/
before_build/
before_compile/
before_docs/
before_emulate/
before_platform_add/
before_platform_rm/
before_platform_ls/
before_plugin_add/
before_plugin_ls/
before_plugin_rm/
before_plugin_search/
before_prepare/
before_run/
before_serve/
pre_package/ <-- Windows 8 and Windows Phone only.

Script Interface

All scripts are run from the project's root directory and have the root directory passes as the first argument. All other options are passed to the script using environment variables:

  • CORDOVA_VERSION - The version of the Cordova-CLI.
  • CORDOVA_PLATFORMS - Comma separated list of platforms that the command applies to (e.g.: android, ios).
  • CORDOVA_PLUGINS - Comma separated list of plugin IDs that the command applies to (e.g.: org.apache.cordova.file, org.apache.cordova.file-transfer)
  • CORDOVA_HOOK - Path to the hook that is being executed.
  • CORDOVA_CMDLINE - The exact command-line arguments passed to cordova (e.g.: cordova run ios --emulate)

If a script returns a non-zero exit code, then the parent cordova command will be aborted.

Writing hooks

We highly recommend writting your hooks using Node.js so that they are cross-platform. Some good examples are shown here:

http://devgirl.org/2013/11/12/three-hooks-your-cordovaphonegap-project-needs/