Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions backend/samfundet/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
RecruitmentInterviewAvailability,
RecruitmentPositionSharedInterviewGroup,
)
from .models.site_banner import SiteBanner

# Common fields:
# ordering = []
Expand All @@ -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 = [
Expand Down
36 changes: 36 additions & 0 deletions backend/samfundet/migrations/0006_sitebanner.py
Original file line number Diff line number Diff line change
@@ -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',
},
),
]
2 changes: 2 additions & 0 deletions backend/samfundet/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
Organization,
UserPreference,
)
from .site_banner import SiteBanner

__all__ = [
'User',
Expand All @@ -26,4 +27,5 @@
'Image',
'Profile',
'UserPreference',
'SiteBanner',
]
33 changes: 33 additions & 0 deletions backend/samfundet/models/site_banner.py
Original file line number Diff line number Diff line change
@@ -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),
)
16 changes: 16 additions & 0 deletions backend/samfundet/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
RecruitmentInterviewAvailability,
RecruitmentPositionSharedInterviewGroup,
)
from .models.site_banner import SiteBanner
from .models.model_choices import RecruitmentStatusChoices, RecruitmentPriorityChoices

if TYPE_CHECKING:
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions backend/samfundet/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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')
Expand Down
28 changes: 28 additions & 0 deletions backend/samfundet/view/site_banners.py
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down
8 changes: 7 additions & 1 deletion frontend/src/Components/SamfOutlet/SamfOutlet.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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 */}
{/* <Navbar /> */}
{/* TODO: Remove the following line when samf4 navbar is enabled */}
{showBanner && <SiteBanner />}
<Navbar />
<div className={styles.navbar_outlet}>{children}</div>
<Footer />
Expand Down
59 changes: 59 additions & 0 deletions frontend/src/Components/SiteBanner/SiteBanner.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
@use 'src/constants' as *;

.banner {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 11;
background: $red-samf;
color: $white;
display: flex;
justify-content: center;
align-items: center;
padding: 0.5rem 1rem;
overflow: hidden;
}

.content {
width: 100%;
max-width: 1200px;
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
text-align: center;
overflow: hidden;
}

.text {
flex: 1;
min-width: 0;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

.link {
color: inherit;
text-decoration: underline;

&:focus-visible {
outline: 2px solid currentcolor;
outline-offset: 2px;
}
}

@media (max-width: 900px) {
.content {
flex-direction: column;
gap: 0.35rem;
}

.text {
white-space: normal;
overflow-wrap: anywhere;
word-break: break-word;
}
}
80 changes: 80 additions & 0 deletions frontend/src/Components/SiteBanner/SiteBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { useQuery } from '@tanstack/react-query';
import { useLayoutEffect, useRef } from 'react';
import { getActiveSiteBanner } from '~/api';
import { useScrollY } from '~/hooks';
import { siteBannerKeys } from '~/queryKeys';
import { dbT } from '~/utils';
import styles from './SiteBanner.module.scss';

type Props = {
hideAfterPx?: number;
};

export function SiteBanner({ hideAfterPx = 1000 }: Props): JSX.Element | null {
const ref = useRef<HTMLDivElement | null>(null);
const scrollY = useScrollY();
const isCoveredByNavbar = scrollY > hideAfterPx;

const { data: visibleBanner } = useQuery({
queryKey: siteBannerKeys.active(),
queryFn: getActiveSiteBanner,
});

useLayoutEffect(() => {
const root = document.documentElement;
const setOffset = (px: number) => root.style.setProperty('--site-banner-offset', `${px}px`);
const el = ref.current;

if (!visibleBanner || !el) {
setOffset(0);
return;
}

const updateOffset = () => {
setOffset(el.offsetHeight);
};

updateOffset();

const observer = new ResizeObserver(updateOffset);
observer.observe(el);
window.addEventListener('resize', updateOffset);

return () => {
observer.disconnect();
window.removeEventListener('resize', updateOffset);
setOffset(0);
};
}, [visibleBanner]);

useLayoutEffect(() => {
document.documentElement.classList.toggle('site-banner-covered', isCoveredByNavbar);

return () => {
document.documentElement.classList.remove('site-banner-covered');
};
}, [isCoveredByNavbar]);

if (!visibleBanner) return null;

const content = <span className={styles.text}>{dbT(visibleBanner, 'text')}</span>;

return (
<div ref={ref} className={styles.banner} role="status" aria-live="polite">
<div className={styles.content}>
{visibleBanner.url ? (
<a
className={styles.link}
href={visibleBanner.url}
target={visibleBanner.new_tab ? '_blank' : undefined}
rel={visibleBanner.new_tab ? 'noopener noreferrer' : undefined}
>
{content}
</a>
) : (
content
)}
</div>
</div>
);
}
7 changes: 7 additions & 0 deletions frontend/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import type {
RoleDto,
RoleUsersDto,
SaksdokumentDto,
SiteBannerDto,
TextItemDto,
UserDto,
UserPreferenceDto,
Expand Down Expand Up @@ -156,6 +157,12 @@ export async function getHomeData(): Promise<HomePageDto> {
return response.data;
}

export async function getActiveSiteBanner(): Promise<SiteBannerDto | null> {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hadde vært fint med en funksjon som returnerer alle bannere i databasen for senere bruk, ikke hast.

const url = BACKEND_DOMAIN + ROUTES.backend.samfundet__site_banners_active;
const response = await axios.get<SiteBannerDto | null>(url, { withCredentials: true });
return response.data;
}

export async function putUserPreference(id: string | number, data: Partial<UserPreferenceDto>): Promise<unknown> {
const url =
BACKEND_DOMAIN +
Expand Down
Loading
Loading