diff --git a/com/templates/com/news_list.jinja b/com/templates/com/news_list.jinja index bcb0eb6e5..ac9a7892d 100644 --- a/com/templates/com/news_list.jinja +++ b/com/templates/com/news_list.jinja @@ -136,25 +136,22 @@ type="EVENT").order_by('dates__start_date') %}
{% trans %}Birthdays{% endtrans %}
- {% if user.is_subscribed %} - {# Cache request for 1 hour #} - {% cache 3600 "birthdays" %} - - {% endcache %} - {% else %} + {%- if user.is_subscribed -%} + + {%- else -%}

{% trans %}You need an up to date subscription to access this content{% endtrans %}

- {% endif %} + {%- endif -%}
diff --git a/com/views.py b/com/views.py index d4136d20e..1b7ab8bce 100644 --- a/com/views.py +++ b/com/views.py @@ -21,7 +21,7 @@ # Place - Suite 330, Boston, MA 02111-1307, USA. # # - +import itertools from datetime import timedelta from smtplib import SMTPRecipientsRefused @@ -374,13 +374,14 @@ def get_context_data(self, **kwargs): kwargs = super().get_context_data(**kwargs) kwargs["NewsDate"] = NewsDate kwargs["timedelta"] = timedelta - kwargs["birthdays"] = ( + kwargs["birthdays"] = itertools.groupby( User.objects.filter( date_of_birth__month=localdate().month, date_of_birth__day=localdate().day, ) .filter(role__in=["STUDENT", "FORMER STUDENT"]) - .order_by("-date_of_birth") + .order_by("-date_of_birth"), + key=lambda u: u.date_of_birth.year, ) return kwargs diff --git a/core/auth_backends.py b/core/auth_backends.py new file mode 100644 index 000000000..7d6fd76cc --- /dev/null +++ b/core/auth_backends.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from django.conf import settings +from django.contrib.auth.backends import ModelBackend +from django.contrib.auth.models import Permission + +from core.models import Group + +if TYPE_CHECKING: + from core.models import User + + +class SithModelBackend(ModelBackend): + """Custom auth backend for the Sith. + + In fact, it's the exact same backend as `django.contrib.auth.backend.ModelBackend`, + with the exception that group permissions are fetched slightly differently. + Indeed, django tries by default to fetch the permissions associated + with all the `django.contrib.auth.models.Group` of a user ; + however, our User model overrides that, so the actual linked group model + is [core.models.Group][]. + Instead of having the relation `auth_perm --> auth_group <-- core_user`, + we have `auth_perm --> auth_group <-- core_group <-- core_user`. + + Thus, this backend make the small tweaks necessary to make + our custom models interact with the django auth. + """ + + def _get_group_permissions(self, user_obj: User): + # union of querysets doesn't work if the queryset is ordered. + # The empty `order_by` here are actually there to *remove* + # any default ordering defined in managers or model Meta + groups = user_obj.groups.order_by() + if user_obj.is_subscribed: + groups = groups.union( + Group.objects.filter(pk=settings.SITH_GROUP_SUBSCRIBERS_ID).order_by() + ) + return Permission.objects.filter( + group__group__in=groups.values_list("pk", flat=True) + ) diff --git a/core/management/commands/populate.py b/core/management/commands/populate.py index 5770c7154..0a26b4b8f 100644 --- a/core/management/commands/populate.py +++ b/core/management/commands/populate.py @@ -23,7 +23,7 @@ from datetime import date, timedelta from io import StringIO from pathlib import Path -from typing import ClassVar +from typing import ClassVar, NamedTuple from django.conf import settings from django.contrib.auth.models import Permission @@ -31,6 +31,7 @@ from django.core.management import call_command from django.core.management.base import BaseCommand from django.db import connection +from django.db.models import Q from django.utils import timezone from django.utils.timezone import localdate from PIL import Image @@ -56,6 +57,18 @@ from subscription.models import Subscription +class PopulatedGroups(NamedTuple): + root: Group + public: Group + subscribers: Group + old_subscribers: Group + sas_admin: Group + com_admin: Group + counter_admin: Group + accounting_admin: Group + pedagogy_admin: Group + + class Command(BaseCommand): ROOT_PATH: ClassVar[Path] = Path(__file__).parent.parent.parent.parent SAS_FIXTURE_PATH: ClassVar[Path] = ( @@ -79,25 +92,7 @@ def handle(self, *args, **options): Sith.objects.create(weekmail_destinations="etudiants@git.an personnel@git.an") Site.objects.create(domain=settings.SITH_URL, name=settings.SITH_NAME) - - root_group = Group.objects.create(name="Root") - public_group = Group.objects.create(name="Public") - subscribers = Group.objects.create(name="Subscribers") - old_subscribers = Group.objects.create(name="Old subscribers") - Group.objects.create(name="Accounting admin") - Group.objects.create(name="Communication admin") - Group.objects.create(name="Counter admin") - Group.objects.create(name="Banned from buying alcohol") - Group.objects.create(name="Banned from counters") - Group.objects.create(name="Banned to subscribe") - Group.objects.create(name="SAS admin") - Group.objects.create(name="Forum admin") - Group.objects.create(name="Pedagogy admin") - self.reset_index("core", "auth") - - change_billing = Permission.objects.get(codename="change_billinginfo") - add_billing = Permission.objects.get(codename="add_billinginfo") - root_group.permissions.add(change_billing, add_billing) + groups = self._create_groups() root = User.objects.create_superuser( id=0, @@ -155,7 +150,7 @@ def handle(self, *args, **options): Counter.edit_groups.through.objects.bulk_create(bar_groups) self.reset_index("counter") - subscribers.viewable_files.add(home_root, club_root) + groups.subscribers.viewable_files.add(home_root, club_root) Weekmail().save() @@ -260,21 +255,11 @@ def handle(self, *args, **options): ) User.groups.through.objects.bulk_create( [ - User.groups.through( - group_id=settings.SITH_GROUP_COUNTER_ADMIN_ID, user=counter - ), - User.groups.through( - group_id=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID, user=comptable - ), - User.groups.through( - group_id=settings.SITH_GROUP_COM_ADMIN_ID, user=comunity - ), - User.groups.through( - group_id=settings.SITH_GROUP_PEDAGOGY_ADMIN_ID, user=tutu - ), - User.groups.through( - group_id=settings.SITH_GROUP_SAS_ADMIN_ID, user=skia - ), + User.groups.through(group=groups.counter_admin, user=counter), + User.groups.through(group=groups.accounting_admin, user=comptable), + User.groups.through(group=groups.com_admin, user=comunity), + User.groups.through(group=groups.pedagogy_admin, user=tutu), + User.groups.through(group=groups.sas_admin, user=skia), ] ) for user in richard, sli, krophil, skia: @@ -335,7 +320,7 @@ def handle(self, *args, **options): content="Fonctionnement de la laverie", ) - public_group.viewable_page.set( + groups.public.viewable_page.set( [syntax_page, services_page, index_page, laundry_page] ) @@ -512,8 +497,10 @@ def handle(self, *args, **options): club=main_club, limit_age=18, ) - subscribers.products.add(cotis, cotis2, refill, barb, cble, cors, carolus) - old_subscribers.products.add(cotis, cotis2) + groups.subscribers.products.add( + cotis, cotis2, refill, barb, cble, cors, carolus + ) + groups.old_subscribers.products.add(cotis, cotis2) mde = Counter.objects.get(name="MDE") mde.products.add(barb, cble, cons, dcons) @@ -616,10 +603,10 @@ def handle(self, *args, **options): start_date="1942-06-12 10:28:45+01", end_date="7942-06-12 10:28:45+01", ) - el.view_groups.add(public_group) + el.view_groups.add(groups.public) el.edit_groups.add(ae_board_group) - el.candidature_groups.add(subscribers) - el.vote_groups.add(subscribers) + el.candidature_groups.add(groups.subscribers) + el.vote_groups.add(groups.subscribers) liste = ElectionList.objects.create(title="Candidature Libre", election=el) listeT = ElectionList.objects.create(title="Troll", election=el) pres = Role.objects.create( @@ -898,3 +885,102 @@ def _create_subscription( start=s.subscription_start, ) s.save() + + def _create_groups(self) -> PopulatedGroups: + perms = Permission.objects.all() + + root_group = Group.objects.create(name="Root") + root_group.permissions.add(*list(perms.values_list("pk", flat=True))) + # public has no permission. + # Its purpose is not to link users to permissions, + # but to other objects (like products) + public_group = Group.objects.create(name="Public") + + subscribers = Group.objects.create(name="Subscribers") + old_subscribers = Group.objects.create(name="Old subscribers") + old_subscribers.permissions.add( + *list( + perms.filter( + codename__in=[ + "view_user", + "view_picture", + "view_album", + "view_peoplepicturerelation", + "add_peoplepicturerelation", + ] + ) + ) + ) + accounting_admin = Group.objects.create(name="Accounting admin") + accounting_admin.permissions.add( + *list( + perms.filter( + Q(content_type__app_label="accounting") + | Q( + codename__in=[ + "view_customer", + "view_product", + "change_product", + "add_product", + "view_producttype", + "change_producttype", + "add_producttype", + "delete_selling", + ] + ) + ).values_list("pk", flat=True) + ) + ) + com_admin = Group.objects.create(name="Communication admin") + com_admin.permissions.add( + *list( + perms.filter(content_type__app_label="com").values_list("pk", flat=True) + ) + ) + counter_admin = Group.objects.create(name="Counter admin") + counter_admin.permissions.add( + *list( + perms.filter( + Q(content_type__app_label__in=["counter", "launderette"]) + & ~Q(codename__in=["delete_product", "delete_producttype"]) + ) + ) + ) + Group.objects.create(name="Banned from buying alcohol") + Group.objects.create(name="Banned from counters") + Group.objects.create(name="Banned to subscribe") + sas_admin = Group.objects.create(name="SAS admin") + sas_admin.permissions.add( + *list( + perms.filter(content_type__app_label="sas").values_list("pk", flat=True) + ) + ) + forum_admin = Group.objects.create(name="Forum admin") + forum_admin.permissions.add( + *list( + perms.filter(content_type__app_label="forum").values_list( + "pk", flat=True + ) + ) + ) + pedagogy_admin = Group.objects.create(name="Pedagogy admin") + pedagogy_admin.permissions.add( + *list( + perms.filter(content_type__app_label="pedagogy").values_list( + "pk", flat=True + ) + ) + ) + self.reset_index("core", "auth") + + return PopulatedGroups( + root=root_group, + public=public_group, + subscribers=subscribers, + old_subscribers=old_subscribers, + com_admin=com_admin, + counter_admin=counter_admin, + accounting_admin=accounting_admin, + sas_admin=sas_admin, + pedagogy_admin=pedagogy_admin, + ) diff --git a/core/models.py b/core/models.py index 347e9bd40..c8375727c 100644 --- a/core/models.py +++ b/core/models.py @@ -578,14 +578,6 @@ def get_display_name(self) -> str: return "%s (%s)" % (self.get_full_name(), self.nick_name) return self.get_full_name() - def get_age(self): - """Returns the age.""" - today = timezone.now() - born = self.date_of_birth - return ( - today.year - born.year - ((today.month, today.day) < (born.month, born.day)) - ) - def get_family( self, godfathers_depth: NonNegativeInt = 4, diff --git a/core/static/bundled/utils/api.ts b/core/static/bundled/utils/api.ts index ac647cd7c..5d72b3b6f 100644 --- a/core/static/bundled/utils/api.ts +++ b/core/static/bundled/utils/api.ts @@ -22,10 +22,13 @@ type PaginatedEndpoint = ( // TODO : If one day a test workflow is made for JS in this project // please test this function. A all cost. +/** + * Load complete dataset from paginated routes. + */ export const paginated = async ( endpoint: PaginatedEndpoint, options?: PaginatedRequest, -) => { +): Promise => { const maxPerPage = 199; const queryParams = options ?? {}; queryParams.query = queryParams.query ?? {}; diff --git a/core/static/bundled/utils/csv.ts b/core/static/bundled/utils/csv.ts new file mode 100644 index 000000000..df1a5ebf0 --- /dev/null +++ b/core/static/bundled/utils/csv.ts @@ -0,0 +1,49 @@ +import type { NestedKeyOf } from "#core:utils/types"; + +interface StringifyOptions { + /** The columns to include in the resulting CSV. */ + columns: readonly NestedKeyOf[]; + /** Content of the first row */ + titleRow?: readonly string[]; +} + +function getNested(obj: T, key: NestedKeyOf) { + const path: (keyof object)[] = key.split(".") as (keyof unknown)[]; + let res = obj[path.shift() as keyof T]; + for (const node of path) { + if (res === null) { + break; + } + res = res[node]; + } + return res; +} + +/** + * Convert the content the string to make sure it won't break + * the resulting csv. + * cf. https://en.wikipedia.org/wiki/Comma-separated_values#Basic_rules + */ +function sanitizeCell(content: string): string { + return `"${content.replace(/"/g, '""')}"`; +} + +export const csv = { + stringify: (objs: T[], options?: StringifyOptions) => { + const columns = options.columns; + const content = objs + .map((obj) => { + return columns + .map((col) => { + return sanitizeCell((getNested(obj, col) ?? "").toString()); + }) + .join(","); + }) + .join("\n"); + if (!options.titleRow) { + return content; + } + const firstRow = options.titleRow.map(sanitizeCell).join(","); + return `${firstRow}\n${content}`; + }, +}; diff --git a/core/static/bundled/utils/types.d.ts b/core/static/bundled/utils/types.d.ts new file mode 100644 index 000000000..e9040c678 --- /dev/null +++ b/core/static/bundled/utils/types.d.ts @@ -0,0 +1,37 @@ +/** + * A key of an object, or of one of its descendants. + * + * Example : + * ```typescript + * interface Foo { + * foo_inner: number; + * } + * + * interface Bar { + * foo: Foo; + * } + * + * const foo = (key: NestedKeyOf) { + * console.log(key); + * } + * + * foo("foo.foo_inner"); // OK + * foo("foo.bar"); // FAIL + * ``` + */ +export type NestedKeyOf = { + [Key in keyof T & (string | number)]: NestedKeyOfHandleValue; +}[keyof T & (string | number)]; + +type NestedKeyOfInner = { + [Key in keyof T & (string | number)]: NestedKeyOfHandleValue< + T[Key], + `['${Key}']` | `.${Key}` + >; +}[keyof T & (string | number)]; + +type NestedKeyOfHandleValue = T extends unknown[] + ? Text + : T extends object + ? Text | `${Text}${NestedKeyOfInner}` + : Text; diff --git a/core/static/core/components/card.scss b/core/static/core/components/card.scss new file mode 100644 index 000000000..1cbb26019 --- /dev/null +++ b/core/static/core/components/card.scss @@ -0,0 +1,96 @@ +@import "core/static/core/colors"; + +@mixin row-layout { + min-height: 100px; + width: 100%; + max-width: 100%; + display: flex; + flex-direction: row; + gap: 10px; + .card-image { + max-width: 75px; + } + .card-content { + flex: 1; + text-align: left; + } +} + +.card { + background-color: $primary-neutral-light-color; + border-radius: 5px; + position: relative; + box-sizing: border-box; + padding: 20px 10px; + height: fit-content; + width: 150px; + display: flex; + flex-direction: column; + align-items: center; + gap: 20px; + + &:hover { + background-color: darken($primary-neutral-light-color, 5%); + } + + &.selected { + animation: bg-in-out 1s ease; + background-color: rgb(216, 236, 255); + } + + .card-image { + width: 100%; + height: 100%; + min-height: 70px; + max-height: 70px; + object-fit: contain; + border-radius: 4px; + line-height: 70px; + } + + i.card-image { + color: black; + text-align: center; + background-color: rgba(173, 173, 173, 0.2); + width: 80%; + } + + .card-content { + color: black; + display: flex; + flex-direction: column; + gap: 5px; + width: 100%; + + p { + font-size: 13px; + margin: 0; + } + + .card-title { + margin: 0; + font-size: 15px; + word-break: break-word; + } + } + + @keyframes bg-in-out { + 0% { + background-color: white; + } + 100% { + background-color: rgb(216, 236, 255); + } + } + + @media screen and (max-width: 765px) { + @include row-layout + } + + // When combined with card, card-row display the card in a row layout, + // whatever the size of the screen. + &.card-row { + @include row-layout + } +} + diff --git a/core/static/core/forms.scss b/core/static/core/forms.scss index 42a4d7197..ae6f8a21d 100644 --- a/core/static/core/forms.scss +++ b/core/static/core/forms.scss @@ -1,95 +1,145 @@ @import "colors"; /** - * Style related to forms + * Style related to forms and form inputs */ -a.button, -button, -input[type="button"], -input[type="submit"], -input[type="reset"], -input[type="file"] { - border: none; - text-decoration: none; - background-color: $background-button-color; - padding: 0.4em; - margin: 0.1em; - font-size: 1.2em; - border-radius: 5px; - color: black; - - &:hover { - background: hsl(0, 0%, 83%); +/** + * Inputs that are not enclosed in a form element. + */ +:not(form) { + a.button, + button, + input[type="button"], + input[type="submit"], + input[type="reset"], + input[type="file"] { + border: none; + text-decoration: none; + background-color: $background-button-color; + padding: 0.4em; + margin: 0.1em; + font-size: 1.2em; + border-radius: 5px; + color: black; + + &:hover { + background: hsl(0, 0%, 83%); + } } -} -a.button, -input[type="button"], -input[type="submit"], -input[type="reset"], -input[type="file"] { - font-weight: bold; -} + a.button, + input[type="button"], + input[type="submit"], + input[type="reset"], + input[type="file"] { + font-weight: bold; + } -a.button:not(:disabled), -button:not(:disabled), -input[type="button"]:not(:disabled), -input[type="submit"]:not(:disabled), -input[type="reset"]:not(:disabled), -input[type="checkbox"]:not(:disabled), -input[type="file"]:not(:disabled) { - cursor: pointer; -} + a.button:not(:disabled), + button:not(:disabled), + input[type="button"]:not(:disabled), + input[type="submit"]:not(:disabled), + input[type="reset"]:not(:disabled), + input[type="checkbox"]:not(:disabled), + input[type="file"]:not(:disabled) { + cursor: pointer; + } -input, -textarea[type="text"], -[type="number"] { - border: none; - text-decoration: none; - background-color: $background-button-color; - padding: 0.4em; - margin: 0.1em; - font-size: 1.2em; - border-radius: 5px; - max-width: 95%; -} + input, + textarea[type="text"], + [type="number"] { + border: none; + text-decoration: none; + background-color: $background-button-color; + padding: 0.4em; + margin: 0.1em; + font-size: 1.2em; + border-radius: 5px; + max-width: 95%; + } -textarea { - border: none; - text-decoration: none; - background-color: $background-button-color; - padding: 7px; - font-size: 1.2em; - border-radius: 5px; - font-family: sans-serif; -} + textarea { + border: none; + text-decoration: none; + background-color: $background-button-color; + padding: 7px; + font-size: 1.2em; + border-radius: 5px; + font-family: sans-serif; + } -select { - border: none; - text-decoration: none; - font-size: 1.2em; - background-color: $background-button-color; - padding: 10px; - border-radius: 5px; - cursor: pointer; -} + select { + border: none; + text-decoration: none; + font-size: 1.2em; + background-color: $background-button-color; + padding: 10px; + border-radius: 5px; + cursor: pointer; + } -a:not(.button) { - text-decoration: none; - color: $primary-dark-color; + a:not(.button) { + text-decoration: none; + color: $primary-dark-color; - &:hover { - color: $primary-light-color; - } + &:hover { + color: $primary-light-color; + } - &:active { - color: $primary-color; + &:active { + color: $primary-color; + } } } form { - margin: 0 auto 10px; + // Input size - used for height/padding calculations + --nf-input-size: 1rem; + + --nf-input-font-size: calc(var(--nf-input-size) * 0.875); + --nf-small-font-size: calc(var(--nf-input-size) * 0.875); + + // Input + --nf-input-color: $text-color; + --nf-input-border-radius: 0.25rem; + --nf-input-placeholder-color: #929292; + --nf-input-border-color: #c0c4c9; + --nf-input-border-width: 1px; + --nf-input-border-style: solid; + --nf-input-border-bottom-width: 2px; + --nf-input-focus-border-color: #3b4ce2; + --nf-input-background-color: #f3f6f7; + + // Valid/invalid + --nf-invalid-input-border-color: var(--nf-input-border-color); + --nf-invalid-input-background-color: var(--nf-input-background-color); + --nf-invalid-input-color: var(--nf-input-color); + --nf-valid-input-border-color: var(--nf-input-border-color); + --nf-valid-input-background-color: var(--nf-input-background-color); + --nf-valid-input-color: inherit; + --nf-invalid-input-border-bottom-color: red; + --nf-valid-input-border-bottom-color: green; + + // Label variables + --nf-label-font-size: var(--nf-small-font-size); + --nf-label-color: #374151; + --nf-label-font-weight: 500; + + // Slider variables + --nf-slider-track-background: #dfdfdf; + --nf-slider-track-height: 0.25rem; + --nf-slider-thumb-size: calc(var(--nf-slider-track-height) * 4); + --nf-slider-track-border-radius: var(--nf-slider-track-height); + --nf-slider-thumb-border-width: 2px; + --nf-slider-thumb-border-focus-width: 1px; + --nf-slider-thumb-border-color: #ffffff; + --nf-slider-thumb-background: var(--nf-input-focus-border-color); + + display: block; + margin: calc(var(--nf-input-size) * 1.5) auto 10px; + line-height: 1; + white-space: nowrap; .helptext { margin-top: .25rem; @@ -107,9 +157,16 @@ form { } } - label { + // ------------- LABEL + label, legend { + font-weight: var(--nf-label-font-weight); display: block; - margin-bottom: 8px; + margin-bottom: calc(var(--nf-input-size) / 2); + white-space: initial; + + + small { + font-style: initial; + } &.required:after { margin-left: 4px; @@ -118,7 +175,555 @@ form { } } + // wrap texts + label, legend, ul.errorlist>li, .helptext { + text-wrap: wrap; + } + .choose_file_widget { display: none; } + + // ------------- SMALL + + small { + display: block; + font-weight: normal; + opacity: 0.75; + font-size: var(--nf-small-font-size); + margin-bottom: calc(var(--nf-input-size) * 0.75); + + &:last-child { + margin-bottom: 0; + } + } + + // ------------- LEGEND + + legend { + font-weight: var(--nf-label-font-weight); + display: block; + margin-bottom: calc(var(--nf-input-size) / 5); + } + + .form-group, + > p, + > div { + margin-top: calc(var(--nf-input-size) / 2); + } + + // ------------ ERROR LIST + ul.errorlist { + list-style-type: none; + margin: 0; + opacity: 60%; + color: var(--nf-invalid-input-border-bottom-color); + + > li { + text-align: left; + margin-top: 5px; + } + } + + input[type="text"], + input[type="email"], + input[type="tel"], + input[type="url"], + input[type="password"], + input[type="number"], + input[type="date"], + input[type="week"], + input[type="time"], + input[type="month"], + input[type="search"], + textarea, + select { + min-width: 300px; + + &.grow { + width: 95%; + } + } + + input[type="text"], + input[type="checkbox"], + input[type="radio"], + input[type="email"], + input[type="tel"], + input[type="url"], + input[type="password"], + input[type="number"], + input[type="date"], + input[type="datetime-local"], + input[type="week"], + input[type="time"], + input[type="month"], + input[type="search"], + textarea, + select { + background: var(--nf-input-background-color); + font-size: var(--nf-input-font-size); + border-color: var(--nf-input-border-color); + border-width: var(--nf-input-border-width); + border-style: var(--nf-input-border-style); + box-shadow: none; + border-radius: var(--nf-input-border-radius); + border-bottom-width: var(--nf-input-border-bottom-width); + color: var(--nf-input-color); + max-width: 95%; + box-sizing: border-box; + padding: calc(var(--nf-input-size) * 0.65); + line-height: normal; + appearance: none; + transition: all 0.15s ease-out; + + // ------------- VALID/INVALID + + &.error { + &:not(:placeholder-shown):invalid { + background-color: var(--nf-invalid-input-background-color); + border-color: var(--nf-valid-input-border-color); + border-bottom-color: var(--nf-invalid-input-border-bottom-color); + color: var(--nf-invalid-input-color); + + // Reset to default when focus + + &:focus { + background-color: var(--nf-input-background-color); + border-color: var(--nf-input-border-color); + color: var(--nf-input-color); + } + } + + &:not(:placeholder-shown):valid { + background-color: var(--nf-valid-input-background-color); + border-color: var(--nf-valid-input-border-color); + border-bottom-color: var(--nf-valid-input-border-bottom-color); + color: var(--nf-valid-input-color); + } + } + + // ------------- DISABLED + + &:disabled { + cursor: not-allowed; + opacity: 0.75; + } + + // -------- PLACEHOLDERS + + &::-webkit-input-placeholder { + color: var(--nf-input-placeholder-color); + letter-spacing: 0; + } + + &:-ms-input-placeholder { + color: var(--nf-input-placeholder-color); + letter-spacing: 0; + } + + &::-moz-placeholder { + color: var(--nf-input-placeholder-color); + letter-spacing: 0; + } + + &:-moz-placeholder { + color: var(--nf-input-placeholder-color); + letter-spacing: 0; + } + + // -------- FOCUS + + &:focus { + outline: none; + border-color: var(--nf-input-focus-border-color); + } + + // -------- ADDITIONAL TEXT BENEATH INPUT FIELDS + + + small { + margin-top: 0.5rem; + } + + // -------- ICONS + + --icon-padding: calc(var(--nf-input-size) * 2.25); + --icon-background-offset: calc(var(--nf-input-size) * 0.75); + + &.icon-left { + background-position: left var(--icon-background-offset) bottom 50%; + padding-left: var(--icon-padding); + background-size: var(--nf-input-size); + } + + &.icon-right { + background-position: right var(--icon-background-offset) bottom 50%; + padding-right: var(--icon-padding); + background-size: var(--nf-input-size); + } + + // When a field has a icon and is autofilled, the background image is removed + // by the browser. To negate this we reset the padding, not great but okay + + &:-webkit-autofill { + padding: calc(var(--nf-input-size) * 0.75) !important; + } + } + + // -------- SEARCH + + input[type="search"] { + &:placeholder-shown { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%236B7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='feather feather-search'%3E%3Ccircle cx='11' cy='11' r='8'/%3E%3Cline x1='21' y1='21' x2='16.65' y2='16.65'/%3E%3C/svg%3E"); + background-position: left calc(var(--nf-input-size) * 0.75) bottom 50%; + padding-left: calc(var(--nf-input-size) * 2.25); + background-size: var(--nf-input-size); + background-repeat: no-repeat; + } + + &::-webkit-search-cancel-button { + -webkit-appearance: none; + width: var(--nf-input-size); + height: var(--nf-input-size); + background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%236B7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='feather feather-x'%3E%3Cline x1='18' y1='6' x2='6' y2='18'/%3E%3Cline x1='6' y1='6' x2='18' y2='18'/%3E%3C/svg%3E"); + } + + &:focus { + padding-left: calc(var(--nf-input-size) * 0.75); + background-position: left calc(var(--nf-input-size) * -1) bottom 50%; + } + } + + // -------- EMAIL + + input[type="email"][class^="icon"] { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%236B7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='feather feather-at-sign'%3E%3Ccircle cx='12' cy='12' r='4'/%3E%3Cpath d='M16 8v5a3 3 0 0 0 6 0v-1a10 10 0 1 0-3.92 7.94'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + } + + // -------- TEL + + input[type="tel"][class^="icon"] { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%236B7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='feather feather-phone'%3E%3Cpath d='M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + } + + // -------- URL + + input[type="url"][class^="icon"] { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%236B7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='feather feather-link'%3E%3Cpath d='M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71'/%3E%3Cpath d='M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + } + + // -------- PASSWORD + + input[type="password"] { + letter-spacing: 2px; + + &[class^="icon"] { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%236B7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='feather feather-lock'%3E%3Crect x='3' y='11' width='18' height='11' rx='2' ry='2'/%3E%3Cpath d='M7 11V7a5 5 0 0 1 10 0v4'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + } + } + + // -------- RANGE + + input[type="range"] { + -webkit-appearance: none; + width: 100%; + cursor: pointer; + + &:focus { + outline: none; + } + + // NOTE: for some reason grouping these doesn't work (just like :placeholder) + + @mixin track { + width: 100%; + height: var(--nf-slider-track-height); + background: var(--nf-slider-track-background); + border-radius: var(--nf-slider-track-border-radius); + } + + @mixin thumb { + height: var(--nf-slider-thumb-size); + width: var(--nf-slider-thumb-size); + border-radius: var(--nf-slider-thumb-size); + background: var(--nf-slider-thumb-background); + border: 0; + border: var(--nf-slider-thumb-border-width) solid var(--nf-slider-thumb-border-color); + appearance: none; + } + + @mixin thumb-focus { + box-shadow: 0 0 0 var(--nf-slider-thumb-border-focus-width) var(--nf-slider-thumb-background); + } + + &::-webkit-slider-runnable-track { + @include track; + } + + &::-moz-range-track { + @include track; + } + + &::-webkit-slider-thumb { + @include thumb; + margin-top: calc( + ( + calc(var(--nf-slider-track-height) - var(--nf-slider-thumb-size)) * + 0.5 + ) + ); + } + + &::-moz-range-thumb { + @include thumb; + box-sizing: border-box; + } + + &:focus::-webkit-slider-thumb { + @include thumb-focus; + } + + &:focus::-moz-range-thumb { + @include thumb-focus; + } + } + + // -------- COLOR + + input[type="color"] { + border: var(--nf-input-border-width) solid var(--nf-input-border-color); + border-bottom-width: var(--nf-input-border-bottom-width); + height: calc(var(--nf-input-size) * 2); + border-radius: var(--nf-input-border-radius); + padding: calc(var(--nf-input-border-width) * 2); + + &:focus { + outline: none; + border-color: var(--nf-input-focus-border-color); + } + + &::-webkit-color-swatch-wrapper { + padding: 5%; + } + + @mixin swatch { + border-radius: calc(var(--nf-input-border-radius) / 2); + border: none; + } + + &::-moz-color-swatch { + @include swatch; + } + + &::-webkit-color-swatch { + @include swatch; + } + } + + // --------------- NUMBER + + input[type="number"] { + width: auto; + } + + // --------------- DATES + + input[type="date"], + input[type="datetime-local"], + input[type="week"], + input[type="month"] { + min-width: 300px; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%236B7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='feather feather-calendar'%3E%3Crect x='3' y='4' width='18' height='18' rx='2' ry='2'/%3E%3Cline x1='16' y1='2' x2='16' y2='6'/%3E%3Cline x1='8' y1='2' x2='8' y2='6'/%3E%3Cline x1='3' y1='10' x2='21' y2='10'/%3E%3C/svg%3E"); + } + + input[type="time"] { + min-width: 6em; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%236B7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='feather feather-clock'%3E%3Ccircle cx='12' cy='12' r='10'/%3E%3Cpolyline points='12 6 12 12 16 14'/%3E%3C/svg%3E"); + } + + input[type="date"], + input[type="datetime-local"], + input[type="week"], + input[type="time"], + input[type="month"] { + background-position: right calc(var(--nf-input-size) * 0.75) bottom 50%; + background-repeat: no-repeat; + background-size: var(--nf-input-size); + + &::-webkit-inner-spin-button, + &::-webkit-calendar-picker-indicator { + -webkit-appearance: none; + cursor: pointer; + opacity: 0; + } + + // FireFox reset + // FF has restricted control of styling the date/time inputs. + // That's why we don't show icons for FF users, and leave basic styling in place. + @-moz-document url-prefix() { + min-width: auto; + width: auto; + background-image: none; + } + } + + // --------------- TEXAREA + + textarea { + height: auto; + } + + // --------------- CHECKBOX/RADIO + + input[type="checkbox"], + input[type="radio"] { + width: var(--nf-input-size); + height: var(--nf-input-size); + padding: inherit; + margin: 0; + display: inline-block; + vertical-align: top; + border-radius: calc(var(--nf-input-border-radius) / 2); + border-width: var(--nf-input-border-width); + cursor: pointer; + background-position: center center; + + &:focus:not(:checked) { + border: var(--nf-input-border-width) solid var(--nf-input-focus-border-color); + outline: none; + } + + &:hover { + border: var(--nf-input-border-width) solid var(--nf-input-focus-border-color); + } + + + label { + display: inline-block; + margin-bottom: 0; + padding-left: calc(var(--nf-input-size) / 2.5); + font-weight: normal; + user-select: none; + cursor: pointer; + max-width: calc(100% - calc(var(--nf-input-size) * 2)); + line-height: normal; + + > small { + margin-top: calc(var(--nf-input-size) / 4); + } + } + } + + input[type="checkbox"] { + &:checked { + background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20' viewBox='0 0 24 24' fill='none' stroke='%23FFFFFF' stroke-width='3' stroke-linecap='round' stroke-linejoin='round' class='feather feather-check'%3E%3Cpolyline points='20 6 9 17 4 12'/%3E%3C/svg%3E") no-repeat center center/85%; + background-color: var(--nf-input-focus-border-color); + border-color: var(--nf-input-focus-border-color); + } + } + + input[type="radio"] { + border-radius: 100%; + + &:checked { + background-color: var(--nf-input-focus-border-color); + border-color: var(--nf-input-focus-border-color); + box-shadow: 0 0 0 3px white inset; + } + } + + // --------------- SWITCH + + --switch-orb-size: var(--nf-input-size); + --switch-orb-offset: calc(var(--nf-input-border-width) * 2); + --switch-width: calc(var(--nf-input-size) * 2.5); + --switch-height: calc( + calc(var(--nf-input-size) * 1.25) + var(--switch-orb-offset) + ); + + input[type="checkbox"].switch { + width: var(--switch-width); + height: var(--switch-height); + border-radius: var(--switch-height); + position: relative; + + &::after { + background: var(--nf-input-border-color); + border-radius: var(--switch-orb-size); + height: var(--switch-orb-size); + left: var(--switch-orb-offset); + position: absolute; + top: 50%; + transform: translateY(-50%); + width: var(--switch-orb-size); + content: ""; + transition: all 0.2s ease-out; + } + + + label { + margin-top: calc(var(--switch-height) / 8); + } + + &:checked { + background: var(--nf-input-focus-border-color) none initial; + + &::after { + transform: translateY(-50%) translateX( + calc(calc(var(--switch-width) / 2) - var(--switch-orb-offset)) + ); + background: white; + } + } + } + + // ---------------- FILE + + input[type="file"] { + background: rgba(0, 0, 0, 0.025); + padding: calc(var(--nf-input-size) / 2); + display: block; + font-weight: normal; + width: 95%; + box-sizing: border-box; + border-radius: var(--nf-input-border-radius); + border: 1px dashed var(--nf-input-border-color); + outline: none; + cursor: pointer; + + &:focus, + &:hover { + border-color: var(--nf-input-focus-border-color); + } + + @mixin button { + background: var(--nf-input-focus-border-color); + border: 0; + appearance: none; + border-radius: var(--nf-input-border-radius); + color: white; + margin-right: 0.75rem; + outline: none; + cursor: pointer; + } + + &::file-selector-button { + @include button(); + } + + &::-webkit-file-upload-button { + @include button(); + } + } + + // ---------------- SELECT + + select { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%236B7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='feather feather-chevron-down'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E"); + background-position: right calc(var(--nf-input-size) * 0.75) bottom 50%; + background-repeat: no-repeat; + background-size: var(--nf-input-size); + } } diff --git a/core/static/core/header.scss b/core/static/core/header.scss index e1430bba6..fd2cae8cc 100644 --- a/core/static/core/header.scss +++ b/core/static/core/header.scss @@ -33,8 +33,8 @@ $hovered-red-text-color: #ff4d4d; flex-direction: row; gap: 10px; - >a { - color: $text-color; + > a { + color: $text-color!important; } &:hover>a { @@ -395,9 +395,9 @@ $hovered-red-text-color: #ff4d4d; } >input[type=text] { - box-sizing: border-box; - max-width: 100%; width: 100%; + min-width: unset; + border: unset; height: 35px; border-radius: 5px; font-size: .9em; diff --git a/core/static/core/style.scss b/core/static/core/style.scss index d7a396d19..a9205e235 100644 --- a/core/static/core/style.scss +++ b/core/static/core/style.scss @@ -19,6 +19,13 @@ body { --loading-stroke: 5px; --loading-duration: 1s; position: relative; + + &.aria-busy-grow { + // Make sure the element take enough place to hold the loading wheel + min-height: calc((var(--loading-size)) * 1.5); + min-width: calc((var(--loading-size)) * 1.5); + overflow: hidden; + } } [aria-busy]:after { @@ -198,6 +205,10 @@ body { margin: 20px auto 0; /*---------------------------------NAV---------------------------------*/ + a.btn { + display: inline-block; + } + .btn { font-size: 15px; font-weight: normal; @@ -252,6 +263,13 @@ body { } } + /** + * A spacer below an element. Somewhat cleaner than putting
everywhere. + */ + .margin-bottom { + margin-bottom: 1.5rem; + } + /*--------------------------------CONTENT------------------------------*/ #quick_notif { width: 100%; @@ -319,7 +337,8 @@ body { margin-left: -125px; box-sizing: border-box; position: fixed; - z-index: 1; + z-index: 10; + /* to get on top of tomselect */ left: 50%; top: 60px; text-align: center; @@ -409,6 +428,31 @@ body { } } + .row { + display: flex; + flex-wrap: wrap; + + $col-gap: 1rem; + $row-gap: 0.5rem; + + &.gap { + column-gap: var($col-gap); + row-gap: var($row-gap); + } + + @for $i from 2 through 5 { + &.gap-#{$i}x { + column-gap: $i * $col-gap; + row-gap: $i * $row-gap; + } + } + + // Make an element of the row take as much space as needed + .grow { + flex: 1; + } + } + /*---------------------------------NEWS--------------------------------*/ #news { display: flex; @@ -1210,40 +1254,6 @@ u, text-decoration: underline; } -#bar-ui { - padding: 0.4em; - display: flex; - flex-wrap: wrap; - flex-direction: row-reverse; - - #products { - flex-basis: 100%; - margin: 0.2em; - overflow: auto; - } - - #click_form { - flex: auto; - margin: 0.2em; - } - - #user_info { - flex: auto; - padding: 0.5em; - margin: 0.2em; - height: 100%; - background: $secondary-neutral-light-color; - - img { - max-width: 70%; - } - - input { - background: white; - } - } -} - /*-----------------------------USER PROFILE----------------------------*/ .user_mini_profile { @@ -1419,16 +1429,6 @@ footer { width: 97%; } -#user_edit { - * { - text-align: center; - } - - img { - width: 100px; - } -} - #cash_summary_form label, .inline { display: inline; diff --git a/core/static/user/user_detail.scss b/core/static/user/user_detail.scss index 87a3d199a..60985f161 100644 --- a/core/static/user/user_detail.scss +++ b/core/static/user/user_detail.scss @@ -1,3 +1,5 @@ +@import "core/static/core/colors"; + main { box-sizing: border-box; display: flex; @@ -69,7 +71,7 @@ main { border-radius: 50%; justify-content: center; align-items: center; - background-color: #f2f2f2; + background-color: $primary-neutral-light-color; > span { font-size: small; diff --git a/core/static/user/user_edit.scss b/core/static/user/user_edit.scss index 94b6ad6c7..888ae729b 100644 --- a/core/static/user/user_edit.scss +++ b/core/static/user/user_edit.scss @@ -1,26 +1,9 @@ - @media (max-width: 750px) { .title { text-align: center; } } -.field-error { - height: auto !important; - - > ul { - list-style-type: none; - margin: 0; - color: indianred; - - > li { - text-align: left !important; - line-height: normal; - margin-top: 5px; - } - } -} - .profile { &-visible { display: flex; @@ -87,11 +70,7 @@ max-height: 100%; } - > i { - font-size: 32px; - } - - >p { + > p { text-align: left !important; width: 100% !important; } @@ -107,16 +86,6 @@ > div { max-width: 100%; - > input { - font-weight: normal; - cursor: pointer; - text-align: left !important; - } - - > button { - min-width: 30%; - } - @media (min-width: 750px) { height: auto; align-items: center; @@ -124,8 +93,8 @@ overflow: hidden; > input { - width: 70%; font-size: .6em; + &::file-selector-button { height: 30px; } @@ -167,7 +136,7 @@ max-width: 100%; } - >* { + > * { width: 100%; max-width: 300px; @@ -181,45 +150,22 @@ } &-content { - - >* { + > * { box-sizing: border-box; text-align: left !important; - line-height: 40px; - max-width: 100%; - width: 100%; - height: 40px; margin: 0; - >* { + > * { text-align: left !important; } } + } - - >textarea { - height: 120px; - min-height: 40px; - min-width: 300px; - max-width: 300px; - line-height: initial; - - @media (max-width: 750px) { - max-width: 100%; - } - } - - >input[type="file"] { - font-size: small; - line-height: 30px; - } - - >input[type="checkbox"] { - width: 20px; - height: 20px; - margin: 0; - float: left; - } + textarea { + height: 7rem; + } + .final-actions { + text-align: center; } } } \ No newline at end of file diff --git a/core/templates/core/macros.jinja b/core/templates/core/macros.jinja index 6ab52cada..84c5b05a0 100644 --- a/core/templates/core/macros.jinja +++ b/core/templates/core/macros.jinja @@ -60,7 +60,7 @@ {% endif %} {% if user.date_of_birth %}
- {{ user.date_of_birth|date("d/m/Y") }} ({{ user.get_age() }}) + {{ user.date_of_birth|date("d/m/Y") }} ({{ user.age }})
{% endif %} @@ -140,7 +140,7 @@ nb_page (str): call to a javascript function or variable returning the maximum number of pages to paginate #} -