diff --git a/backend/samfundet/admin.py b/backend/samfundet/admin.py index 8189cfb7c..a44e1863b 100644 --- a/backend/samfundet/admin.py +++ b/backend/samfundet/admin.py @@ -66,6 +66,7 @@ RecruitmentInterviewAvailability, RecruitmentPositionSharedInterviewGroup, ) +from .models.site_banner import SiteBanner # Common fields: # ordering = [] @@ -90,6 +91,14 @@ class OccupiedTimeAdmin(admin.ModelAdmin): list_select_related = True +@admin.register(SiteBanner) +class SiteBannerAdmin(CustomBaseAdmin): + list_display = ['id', 'start_at', 'end_at', 'updated_at'] + search_fields = ['text_nb', 'text_en'] + ordering = ['-start_at', '-created_at'] + list_select_related = True + + @admin.register(User) class UserAdmin(CustomGuardedUserAdmin): sortable_by = [ diff --git a/backend/samfundet/migrations/0006_sitebanner.py b/backend/samfundet/migrations/0006_sitebanner.py new file mode 100644 index 000000000..ac4d56083 --- /dev/null +++ b/backend/samfundet/migrations/0006_sitebanner.py @@ -0,0 +1,36 @@ +# Generated by Django 5.2.11 on 2026-04-23 13:57 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('samfundet', '0005_medlemsinfo'), + ] + + operations = [ + migrations.CreateModel( + name='SiteBanner', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('version', models.PositiveIntegerField(blank=True, default=0, editable=False, null=True)), + ('created_at', models.DateTimeField(blank=True, editable=False, null=True)), + ('updated_at', models.DateTimeField(blank=True, editable=False, null=True)), + ('text_nb', models.CharField(max_length=128)), + ('text_en', models.CharField(max_length=128)), + ('url', models.CharField(blank=True, max_length=500, null=True)), + ('new_tab', models.BooleanField(default=False)), + ('start_at', models.DateTimeField()), + ('end_at', models.DateTimeField(blank=True, null=True)), + ('created_by', models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ('updated_by', models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Site banner', + 'verbose_name_plural': 'Site banners', + }, + ), + ] diff --git a/backend/samfundet/models/__init__.py b/backend/samfundet/models/__init__.py index 8e427388b..a8f1908ce 100644 --- a/backend/samfundet/models/__init__.py +++ b/backend/samfundet/models/__init__.py @@ -16,6 +16,7 @@ Organization, UserPreference, ) +from .site_banner import SiteBanner __all__ = [ 'User', @@ -26,4 +27,5 @@ 'Image', 'Profile', 'UserPreference', + 'SiteBanner', ] diff --git a/backend/samfundet/models/site_banner.py b/backend/samfundet/models/site_banner.py new file mode 100644 index 000000000..a9fec6160 --- /dev/null +++ b/backend/samfundet/models/site_banner.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from django.db import models +from django.utils import timezone + +from root.utils.mixins import CustomBaseModel + + +class SiteBanner(CustomBaseModel): + text_nb = models.CharField(max_length=128) + text_en = models.CharField(max_length=128) + + url = models.CharField(max_length=500, blank=True, null=True) + new_tab = models.BooleanField(default=False) + + start_at = models.DateTimeField() + end_at = models.DateTimeField(null=True, blank=True) + + class Meta: + verbose_name = 'Site banner' + verbose_name_plural = 'Site banners' + + def __str__(self) -> str: + return f'{self.text_nb[:40]}' + + @classmethod + def active(cls) -> models.QuerySet: + now = timezone.now() + return cls.objects.filter( + start_at__lte=now, + ).filter( + models.Q(end_at__isnull=True) | models.Q(end_at__gte=now), + ) diff --git a/backend/samfundet/serializers.py b/backend/samfundet/serializers.py index 7ea8f8ffd..f224009ac 100644 --- a/backend/samfundet/serializers.py +++ b/backend/samfundet/serializers.py @@ -65,6 +65,7 @@ RecruitmentInterviewAvailability, RecruitmentPositionSharedInterviewGroup, ) +from .models.site_banner import SiteBanner from .models.model_choices import RecruitmentStatusChoices, RecruitmentPriorityChoices if TYPE_CHECKING: @@ -284,6 +285,21 @@ class Meta: fields = '__all__' +class SiteBannerSerializer(CustomBaseSerializer): + class Meta: + model = SiteBanner + fields = [ + 'id', + 'version', + 'text_nb', + 'text_en', + 'url', + 'new_tab', + 'start_at', + 'end_at', + ] + + class ChangePasswordSerializer(serializers.Serializer): current_password = serializers.CharField(required=True) new_password = serializers.CharField(required=True) diff --git a/backend/samfundet/urls.py b/backend/samfundet/urls.py index 5bd564529..ce5a4eb69 100644 --- a/backend/samfundet/urls.py +++ b/backend/samfundet/urls.py @@ -10,6 +10,7 @@ import samfundet.view.mdb_views import samfundet.view.user_views import samfundet.view.event_views +import samfundet.view.site_banners import samfundet.view.sulten_views import samfundet.view.general_views from samfundet.view import billig_views @@ -45,6 +46,7 @@ router.register('organizations', samfundet.view.general_views.OrganizationView, 'organizations') router.register('merch', samfundet.view.general_views.MerchView, 'merch') router.register('role', samfundet.view.general_views.RoleView, 'role') +router.register('site-banners', samfundet.view.site_banners.SiteBannerView, 'site_banners') ########## Recruitment ########## router.register('recruitment', recruitment_views.RecruitmentView, 'recruitment') diff --git a/backend/samfundet/view/site_banners.py b/backend/samfundet/view/site_banners.py new file mode 100644 index 000000000..7b4a742eb --- /dev/null +++ b/backend/samfundet/view/site_banners.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from rest_framework import decorators +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.viewsets import ReadOnlyModelViewSet +from rest_framework.permissions import AllowAny + +from django.db import models + +from samfundet.serializers import SiteBannerSerializer +from samfundet.models.site_banner import SiteBanner + + +class SiteBannerView(ReadOnlyModelViewSet): + permission_classes = [AllowAny] + serializer_class = SiteBannerSerializer + + def get_queryset(self) -> models.QuerySet: + return SiteBanner.active().order_by('-start_at', '-created_at') + + @decorators.action(detail=False, methods=['get'], url_path='active') + def active(self, request: Request) -> Response: + banner = self.get_queryset().first() + if banner is None: + return Response(None) + serializer = self.get_serializer(banner) + return Response(serializer.data) diff --git a/frontend/src/Components/NavbarSamfThree/Navbar.module.scss b/frontend/src/Components/NavbarSamfThree/Navbar.module.scss index fbcd81d9c..803cbd823 100644 --- a/frontend/src/Components/NavbarSamfThree/Navbar.module.scss +++ b/frontend/src/Components/NavbarSamfThree/Navbar.module.scss @@ -44,7 +44,8 @@ $navbar-link-transparent-hover-gradient-bottom: transparent; /* Main navbar */ #navbar_container { position: fixed; - top: 0; + top: var(--site-banner-offset, 0); + transition: top 260ms ease-out; width: 100%; height: $navbar-height; background: $navbar-bg-dark; @@ -57,6 +58,10 @@ $navbar-link-transparent-hover-gradient-bottom: transparent; box-shadow: 0 0 25px 5px rgba(0, 0, 0, 0); } +:global(.site-banner-covered) #navbar_container { + top: 0; +} + .navbar_inner { flex: 1; max-width: 1200px; diff --git a/frontend/src/Components/SamfOutlet/SamfOutlet.tsx b/frontend/src/Components/SamfOutlet/SamfOutlet.tsx index e7ee79a4c..555c47d44 100644 --- a/frontend/src/Components/SamfOutlet/SamfOutlet.tsx +++ b/frontend/src/Components/SamfOutlet/SamfOutlet.tsx @@ -1,6 +1,7 @@ import type { ReactNode } from 'react'; -import { Outlet } from 'react-router'; +import { Outlet, useLocation } from 'react-router'; import { Navbar } from '~/Components/NavbarSamfThree/Navbar'; +import { SiteBanner } from '~/Components/SiteBanner/SiteBanner'; import { Footer } from '../Footer'; import styles from './SamfOutlet.module.scss'; @@ -13,11 +14,16 @@ export function SamfOutlet() { } export function SamfLayout({ children }: { children: ReactNode }) { + const location = useLocation(); + + const showBanner = location.pathname === '/' || location.pathname === '/home/'; + return ( <> {/* TODO: Uncomment the following line when samf4 navbar is enabled */} {/* */} {/* TODO: Remove the following line when samf4 navbar is enabled */} + {showBanner && }
{children}