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 */}
{/*