Skip to content
Open
Show file tree
Hide file tree
Changes from all 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;
}
}
82 changes: 82 additions & 0 deletions frontend/src/Components/SiteBanner/SiteBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { useQuery } from '@tanstack/react-query';
import { useLayoutEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
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 { i18n } = useTranslation();
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', i18n.language)}</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