From 376d25b3a07cc94faa1bba05289509435ae9b840 Mon Sep 17 00:00:00 2001 From: Joan Code <172996447+joan-code6@users.noreply.github.com> Date: Mon, 14 Sep 2026 12:44:05 +0200 Subject: [PATCH] feat: add native Dateiverteilung page --- src/App.tsx | 3 + src/components/dashboard/Dashboard.tsx | 4 + .../dateiverteilung/Dateiverteilung.tsx | 293 ++++++++++++++++++ src/components/demo/demoData.ts | 1 + src/components/demo/mockApi.ts | 39 +++ src/components/layout/Layout.tsx | 6 + src/components/search/GlobalSearch.tsx | 30 +- src/services/api.ts | 23 ++ src/types/index.ts | 33 ++ src/utils/moduleCache.ts | 5 +- src/utils/sidebarNavigation.ts | 3 + 11 files changed, 433 insertions(+), 7 deletions(-) create mode 100644 src/components/dateiverteilung/Dateiverteilung.tsx diff --git a/src/App.tsx b/src/App.tsx index 91f589b4..39dae579 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -21,6 +21,7 @@ import PrivacyPolicy from './components/legal/PrivacyPolicy'; import Timetable from './components/timetable/Timetable'; import StudyGroups from './components/study-groups/StudyGroups'; import Dateispeicher from './components/dateispeicher/Dateispeicher'; +import Dateiverteilung from './components/dateiverteilung/Dateiverteilung'; import Vertretungsplan from './components/vertretungsplan/Vertretungsplan'; import CustomBackend from './components/settings/CustomBackend'; import Onboarding from './components/onboarding/Onboarding'; @@ -87,6 +88,7 @@ const AppRoutes: React.FC = () => { } /> } /> } /> + } /> } /> } /> } /> @@ -124,6 +126,7 @@ const AppRoutes: React.FC = () => { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/components/dashboard/Dashboard.tsx b/src/components/dashboard/Dashboard.tsx index d3acc34d..412260d2 100644 --- a/src/components/dashboard/Dashboard.tsx +++ b/src/components/dashboard/Dashboard.tsx @@ -138,6 +138,10 @@ const Dashboard: React.FC = () => { navigate(`${basePath}/dateispeicher`); return; } + if (moduleLinks.includes('/dateiverteilung.php') || module.name.toLowerCase().includes('dateiverteilung')) { + navigate(`${basePath}/dateiverteilung`); + return; + } const moduleName = module.name.toLowerCase(); const isDsbModule = moduleLinks.includes('dsb') || moduleName.includes('dsb'); const isNativeSubstitutionPlan = !isDsbModule && ( diff --git a/src/components/dateiverteilung/Dateiverteilung.tsx b/src/components/dateiverteilung/Dateiverteilung.tsx new file mode 100644 index 00000000..a6a00691 --- /dev/null +++ b/src/components/dateiverteilung/Dateiverteilung.tsx @@ -0,0 +1,293 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import axios from 'axios'; +import { + ArrowDownTrayIcon, + ArrowPathIcon, + ArrowTopRightOnSquareIcon, + DocumentTextIcon, + InboxArrowDownIcon, + MagnifyingGlassIcon, +} from '@heroicons/react/24/outline'; +import { useAuth } from '../../contexts/AuthContext'; +import { dateiverteilungAPI } from '../../services/api'; +import type { DateiverteilungDistribution, DateiverteilungFile } from '../../types'; +import SEO from '../seo/SEO'; + +type Filter = 'all' | 'unread'; + +function searchableText(distribution: DateiverteilungDistribution): string { + return [ + distribution.title, + distribution.description, + distribution.source, + distribution.created_at, + ...distribution.files.map(file => file.name), + ].filter(Boolean).join(' ').toLocaleLowerCase('de-DE'); +} + +function sourceInitial(source: string): string { + return (source.trim()[0] || 'S').toLocaleUpperCase('de-DE'); +} + +const Dateiverteilung: React.FC = () => { + const { token } = useAuth(); + const [distributions, setDistributions] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [query, setQuery] = useState(''); + const [filter, setFilter] = useState('all'); + const [reloadKey, setReloadKey] = useState(0); + const [downloading, setDownloading] = useState(''); + const forceRefresh = useRef(false); + const downloadController = useRef(null); + + useEffect(() => () => downloadController.current?.abort(), []); + + useEffect(() => { + if (!token) { + setLoading(false); + return undefined; + } + const controller = new AbortController(); + const refresh = forceRefresh.current; + forceRefresh.current = false; + setLoading(true); + setError(''); + + dateiverteilungAPI.getOverview(token, refresh, controller.signal) + .then(response => { + if (!response.success) { + throw new Error(response.error || 'Die Dateiverteilung konnte nicht geladen werden.'); + } + setDistributions(Array.isArray(response.distributions) ? response.distributions : []); + }) + .catch(cause => { + if (axios.isCancel(cause)) return; + setError(cause instanceof Error ? cause.message : 'Die Dateiverteilung konnte nicht geladen werden.'); + }) + .finally(() => { + if (!controller.signal.aborted) setLoading(false); + }); + + return () => controller.abort(); + }, [reloadKey, token]); + + const visibleGroups = useMemo(() => { + const needle = query.trim().toLocaleLowerCase('de-DE'); + const visible = distributions.filter(distribution => ( + (filter === 'all' || distribution.unread) + && (!needle || searchableText(distribution).includes(needle)) + )); + const groups = new Map(); + for (const distribution of visible) { + const source = distribution.source?.trim() || 'Schulportal'; + groups.set(source, [...(groups.get(source) || []), distribution]); + } + return [...groups.entries()]; + }, [distributions, filter, query]); + + const unreadCount = distributions.filter(item => item.unread).length; + const fileCount = distributions.reduce((count, item) => count + item.files.length, 0); + + const downloadFile = async (distribution: DateiverteilungDistribution, file: DateiverteilungFile) => { + if (!token || downloading) return; + const key = `${distribution.id}:${file.id}`; + const controller = new AbortController(); + downloadController.current = controller; + setDownloading(key); + setError(''); + try { + const blob = await dateiverteilungAPI.downloadFile(token, file.download_url, controller.signal); + const objectUrl = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = objectUrl; + link.download = file.name; + document.body.appendChild(link); + link.click(); + link.remove(); + window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1000); + } catch (cause) { + if (axios.isCancel(cause)) return; + setError(cause instanceof Error ? cause.message : 'Die Datei konnte nicht heruntergeladen werden.'); + } finally { + if (downloadController.current === controller) { + downloadController.current = null; + setDownloading(''); + } + } + }; + + if (!token) { + return ( +
+

Nicht authentifiziert

+

Bitte melde dich an, um verteilte Dateien zu sehen.

+
+ ); + } + + return ( +
+ +
+
+
+
+
+ + Für dich verteilt +
+

Dateiverteilung

+

+ Finde Elternbriefe, persönliche Dokumente und Zugänge dort, woher sie kommen. +

+
+ +
+ +
+ +
+ {(['all', 'unread'] as const).map(value => ( + + ))} +
+
+
+ + {error && ( +
+ {error} +
+ )} + + {loading ? ( +
+
+ + Verteilte Dateien werden geladen … +
+
+ ) : visibleGroups.length === 0 ? ( +
+ +

Keine passenden Verteilungen

+

+ {query || filter === 'unread' ? 'Ändere die Suche oder zeige alle Verteilungen an.' : 'Für dich wurden derzeit keine Dateien oder Hinweise bereitgestellt.'} +

+
+ ) : ( +
+

{fileCount} Dateien in {distributions.length} Verteilungen

+ {visibleGroups.map(([source, items]) => ( +
+
+
+
+ {sourceInitial(source)} +
+
+

Herkunft

+

{source}

+
+
+
+ +
+ {items.map(distribution => ( +
+
+ ))} +
+
+ ))} +
+ )} +
+
+ ); +}; + +export default Dateiverteilung; diff --git a/src/components/demo/demoData.ts b/src/components/demo/demoData.ts index 95ba9be8..54cb8c04 100644 --- a/src/components/demo/demoData.ts +++ b/src/components/demo/demoData.ts @@ -26,6 +26,7 @@ export const demoModules: Module[] = [ { name: 'Nachrichten', url: 'https://schulportal.hessen.de/nachrichten.php', direct_url: 'https://schulportal.hessen.de/nachrichten.php', proxy_app: false, color: '#0891b2', logo: 'fa fa-envelope-o', folders: ['Kommunikation'], target: '_self' }, { name: 'Kalender', url: 'https://schulportal.hessen.de/kalender.php', direct_url: 'https://schulportal.hessen.de/kalender.php', proxy_app: false, color: '#dc2626', logo: 'fa fa-calendar-o', folders: ['Schule'], target: '_self' }, { name: 'Dateispeicher', url: 'https://schulportal.hessen.de/dateispeicher.php', direct_url: 'https://schulportal.hessen.de/dateispeicher.php', proxy_app: false, color: '#0f766e', logo: 'fa fa-folder-open-o', folders: ['Schule'], target: '_self' }, + { name: 'Dateiverteilung', url: 'https://schulportal.hessen.de/dateiverteilung.php', direct_url: 'https://schulportal.hessen.de/dateiverteilung.php', proxy_app: false, color: '#15803d', logo: 'fa fa-files-o', folders: ['Schule'], target: '_self' }, { name: 'Vertretungsplan', url: 'https://schulportal.hessen.de/vertretungsplan.php', direct_url: 'https://schulportal.hessen.de/vertretungsplan.php', proxy_app: false, color: '#7c3aed', logo: 'fa fa-list-alt', folders: ['Schule'], target: '_self' }, { name: 'DSBmobile', url: 'https://dsb.hessen.de/dsb.php', direct_url: 'https://dsb.hessen.de/dsb.php', proxy_app: false, color: '#64748b', logo: 'fa fa-retweet', folders: ['Schule'], target: '_self' }, { name: 'Klassenbuch', url: 'https://schulportal.hessen.de/klassenbuch.php', direct_url: 'https://schulportal.hessen.de/klassenbuch.php', proxy_app: false, color: '#059669', logo: 'fa fa-book', folders: ['Schule'], target: '_blank' }, diff --git a/src/components/demo/mockApi.ts b/src/components/demo/mockApi.ts index 89bdcb22..d63baa49 100644 --- a/src/components/demo/mockApi.ts +++ b/src/components/demo/mockApi.ts @@ -143,6 +143,39 @@ const mockDateispeicherNodes = { }, }; +const mockDateiverteilungen = [ + { + id: 'klassenfahrt-2026', + title: 'Einverständniserklärung zur Klassenfahrt', + description: 'Bitte unterschrieben bis Freitag bei der Klassenleitung abgeben.', + source: 'Klassenleitung 9C', + created_at: '10.09.2026', + unread: true, + files: [{ id: 'einverstaendnis.pdf', name: 'Einverständniserklärung.pdf', size: '184 KB', download_url: 'https://start.schulportal.hessen.de/dateiverteilung.php?a=download&v=17&f=einverstaendnis.pdf' }], + links: [], + }, + { + id: 'mathe-zugang', + title: 'Persönlicher Zugang zur Lernplattform', + description: 'Die Zugangsdaten sind nur für dich bestimmt.', + source: 'Mathematik 9c', + created_at: '08.09.2026', + unread: true, + files: [{ id: 'zugang.txt', name: 'Zugangsdaten-Mia.txt', size: '1 KB', download_url: 'https://start.schulportal.hessen.de/dateiverteilung.php?a=download&v=18&f=zugang.txt' }], + links: [{ label: 'Lernplattform öffnen', url: 'https://example.invalid/lernen' }], + }, + { + id: 'schulfest', + title: 'Informationen zum Schulfest', + description: 'Aufbau ab 09:00 Uhr auf dem Schulhof.', + source: 'Schulleitung', + created_at: '01.09.2026', + unread: false, + files: [{ id: 'lageplan.pdf', name: 'Lageplan-Schulfest.pdf', size: '412 KB', download_url: 'https://start.schulportal.hessen.de/dateiverteilung.php?a=download&v=19&f=lageplan.pdf' }], + links: [], + }, +]; + const mockMessageHeaders = [ { Id: 'dm-1', Uniquid: 'uq-1', Sender: 'Frau Neumann', Betreff: 'Deutsch: Gedichtvergleich für Montag', Papierkorb: '0', private: 0, WeitereEmpfaenger: '', empf: [demoUser.username], unread: true, date: hoursAgo(8) }, { Id: 'dm-2', Uniquid: 'uq-2', Sender: 'Herr Vogel', Betreff: 'Mathematik: Abgabe zum Funktionsgraphen', Papierkorb: '0', private: 0, WeitereEmpfaenger: '', empf: [demoUser.username], unread: true, date: daysAgo(1) }, @@ -710,6 +743,12 @@ export function getMockResponse(url: string, method: string, config: any): { dat const fileId = u.split('/').pop(); return { status: 200, data: new Blob([`Demo-Datei ${fileId}`], { type: 'text/plain' }) }; } + if (u === '/dateiverteilung' && method === 'get') { + return { status: 200, data: { success: true, distributions: mockDateiverteilungen, distribution_count: mockDateiverteilungen.length, file_count: mockDateiverteilungen.reduce((count, item) => count + item.files.length, 0), unread_count: mockDateiverteilungen.filter(item => item.unread).length } }; + } + if (u === '/dateiverteilung/file' && method === 'get') { + return { status: 200, data: new Blob(['Persönliche Demo-Datei'], { type: 'text/plain' }) }; + } if (u === '/vertretungsplan' && method === 'get') { return { status: 200, data: mockVertretungsplan }; } if (u === '/lerngruppen' && method === 'get') { return { status: 200, data: { success: true, groups: mockStudyGroups, group_count: mockStudyGroups.length, exams: mockStudyGroupExams, exam_count: mockStudyGroupExams.length } }; diff --git a/src/components/layout/Layout.tsx b/src/components/layout/Layout.tsx index 32195a0d..9c553dbf 100644 --- a/src/components/layout/Layout.tsx +++ b/src/components/layout/Layout.tsx @@ -21,6 +21,7 @@ import { ClipboardDocumentListIcon, ClipboardDocumentCheckIcon, FolderIcon, + DocumentDuplicateIcon, MagnifyingGlassIcon, MinusIcon, } from '@heroicons/react/24/outline'; @@ -60,6 +61,7 @@ const Layout: React.FC = ({ children, basePath = '' }) => { const [isSearchOpen, setIsSearchOpen] = React.useState(false); const [showLogoutConfirmation, setShowLogoutConfirmation] = React.useState(false); const [hasNativeDateispeicher, setHasNativeDateispeicher] = React.useState(false); + const [hasNativeDateiverteilung, setHasNativeDateiverteilung] = React.useState(false); const [hasNativeSubstitutionPlan, setHasNativeSubstitutionPlan] = React.useState(false); const [hasDsbModule, setHasDsbModule] = React.useState(false); const [hasWahlenModule, setHasWahlenModule] = React.useState(false); @@ -100,6 +102,7 @@ const Layout: React.FC = ({ children, basePath = '' }) => { const applyModuleAvailability = (modules: CachedModule[]) => { const availability = getModuleAvailability(modules); setHasNativeDateispeicher(availability.hasNativeDateispeicher); + setHasNativeDateiverteilung(availability.hasNativeDateiverteilung); setHasNativeSubstitutionPlan(availability.hasNativeSubstitutionPlan); setHasDsbModule(availability.hasDsbModule); setHasWahlenModule(availability.hasWahlenModule); @@ -142,6 +145,7 @@ const Layout: React.FC = ({ children, basePath = '' }) => { dashboard: { name: 'Dashboard', href: `${basePath}/dashboard`, icon: HomeIcon }, messages: { name: 'Nachrichten', href: `${basePath}/messages`, icon: ChatBubbleLeftRightIcon }, dateispeicher: { name: 'Dateispeicher', href: `${basePath}/dateispeicher`, icon: FolderIcon }, + dateiverteilung: { name: 'Dateiverteilung', href: `${basePath}/dateiverteilung`, icon: DocumentDuplicateIcon }, vertretungsplan: { name: 'Vertretungsplan', href: `${basePath}/vertretungsplan`, icon: ClipboardDocumentListIcon }, dsb: { name: 'DSBmobile', href: `${basePath}/dsb`, icon: ClipboardDocumentListIcon }, courses: { name: 'Mein Unterricht', href: `${basePath}/courses`, icon: AcademicCapIcon }, @@ -155,6 +159,7 @@ const Layout: React.FC = ({ children, basePath = '' }) => { const availableItems = new Set([ 'search', 'divider', 'dashboard', 'messages', 'courses', 'timetable', 'study-groups', 'calendar', 'profile', 'settings', ...(hasNativeDateispeicher ? ['dateispeicher' as const] : []), + ...(hasNativeDateiverteilung ? ['dateiverteilung' as const] : []), ...(hasNativeSubstitutionPlan ? ['vertretungsplan' as const] : []), ...(hasDsbModule ? ['dsb' as const] : []), ...(hasWahlenModule ? ['wahlen' as const] : []), @@ -235,6 +240,7 @@ const Layout: React.FC = ({ children, basePath = '' }) => { onClose={() => setIsSearchOpen(false)} basePath={basePath} hasNativeDateispeicher={hasNativeDateispeicher} + hasNativeDateiverteilung={hasNativeDateiverteilung} hasNativeSubstitutionPlan={hasNativeSubstitutionPlan} hasDsbModule={hasDsbModule} /> diff --git a/src/components/search/GlobalSearch.tsx b/src/components/search/GlobalSearch.tsx index 7bfd999e..2662c2ce 100644 --- a/src/components/search/GlobalSearch.tsx +++ b/src/components/search/GlobalSearch.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useRef, useState, useCallback, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import { useAuth } from '../../contexts/AuthContext'; -import { authAPI, messagesAPI, calendarAPI, coursesAPI, appsAPI, searchAPI, studyGroupsAPI, timetableAPI } from '../../services/api'; +import { authAPI, messagesAPI, calendarAPI, coursesAPI, appsAPI, searchAPI, studyGroupsAPI, timetableAPI, dateiverteilungAPI } from '../../services/api'; import type { SemanticSearchResult } from '../../services/api'; import { readModulesCache } from '../../utils/moduleCache'; import type { CachedModule } from '../../utils/moduleCache'; @@ -15,6 +15,7 @@ import { Cog6ToothIcon, ClipboardDocumentListIcon, FolderIcon, + DocumentDuplicateIcon, ArrowPathIcon, ClockIcon, UserGroupIcon, @@ -35,6 +36,7 @@ interface GlobalSearchProps { onClose: () => void; basePath?: string; hasNativeDateispeicher?: boolean; + hasNativeDateiverteilung?: boolean; hasNativeSubstitutionPlan?: boolean; hasDsbModule?: boolean; } @@ -60,6 +62,7 @@ function moduleInAppHref( const moduleLinks = `${module.url || ''} ${module.direct_url || ''}`.toLowerCase(); const moduleName = String(module.name || '').toLowerCase(); const isDateispeicher = moduleName.includes('dateispeicher') || moduleLinks.includes('/dateispeicher.php'); + const isDateiverteilung = moduleName.includes('dateiverteilung') || moduleLinks.includes('/dateiverteilung.php'); const isWahlen = moduleName.includes('wahlen') || moduleLinks.includes('/oberstufenwahl.php'); const isDsbModule = moduleName.includes('dsb') || moduleLinks.includes('dsb'); const isNativeSubstitutionPlan = !isDsbModule && ( @@ -68,8 +71,10 @@ function moduleInAppHref( const nativePlanHref = planNavigation.find(item => item.href.endsWith('/vertretungsplan'))?.href; const dsbHref = planNavigation.find(item => item.href.endsWith('/dsb'))?.href; const dateispeicherHref = planNavigation.find(item => item.href.endsWith('/dateispeicher'))?.href; + const dateiverteilungHref = planNavigation.find(item => item.href.endsWith('/dateiverteilung'))?.href; const wahlenHref = `${basePath}/wahlen`; return (isDateispeicher && (dateispeicherHref || `${basePath}/dateispeicher`)) + || (isDateiverteilung && (dateiverteilungHref || `${basePath}/dateiverteilung`)) || (isWahlen && wahlenHref) || (isNativeSubstitutionPlan && (nativePlanHref || `${basePath}/vertretungsplan`)) || (isDsbModule && (dsbHref || `${basePath}/dsb`)) @@ -82,6 +87,7 @@ const CATEGORY_ICONS: Record Unterricht: AcademicCapIcon, Kalender: CalendarDaysIcon, Module: HomeIcon, + Dateiverteilung: DocumentDuplicateIcon, Vertretungsplan: ClipboardDocumentListIcon, Stundenplan: ClockIcon, Lerngruppen: UserGroupIcon, @@ -274,6 +280,7 @@ export default function GlobalSearch({ onClose, basePath = '', hasNativeDateispeicher = false, + hasNativeDateiverteilung = false, hasNativeSubstitutionPlan = false, hasDsbModule = false, }: GlobalSearchProps) { @@ -291,9 +298,10 @@ export default function GlobalSearch({ const [semanticResults, setSemanticResults] = useState([]); const planNavigation = useMemo(() => { - const moduleItems: NavigationItem[] = hasNativeDateispeicher - ? [{ name: 'Dateispeicher', href: `${basePath}/dateispeicher`, icon: FolderIcon, cat: 'Module' }] - : []; + const moduleItems: NavigationItem[] = [ + ...(hasNativeDateispeicher ? [{ name: 'Dateispeicher', href: `${basePath}/dateispeicher`, icon: FolderIcon, cat: 'Module' }] : []), + ...(hasNativeDateiverteilung ? [{ name: 'Dateiverteilung', href: `${basePath}/dateiverteilung`, icon: DocumentDuplicateIcon, cat: 'Module' }] : []), + ]; if (hasNativeSubstitutionPlan) { return [ ...moduleItems, @@ -304,7 +312,7 @@ export default function GlobalSearch({ return hasDsbModule ? [...moduleItems, { name: 'Vertretungsplan', href: `${basePath}/dsb`, icon: ClipboardDocumentListIcon, cat: 'Vertretungsplan' }] : moduleItems; - }, [basePath, hasDsbModule, hasNativeDateispeicher, hasNativeSubstitutionPlan]); + }, [basePath, hasDsbModule, hasNativeDateispeicher, hasNativeDateiverteilung, hasNativeSubstitutionPlan]); const cachedModules = readModulesCache(user); const cacheResults = useMemo( @@ -429,6 +437,16 @@ export default function GlobalSearch({ })); return [...groups, ...exams]; }), + ...(hasNativeDateiverteilung ? [dateiverteilungAPI.getOverview(token, false, controller.signal).then(res => ( + !res.success ? [] : res.distributions.filter(distribution => searchText(query, distribution)).map(distribution => ({ + id: `api-distribution-${distribution.id}`, + title: distribution.title, + subtitle: [distribution.source, distribution.created_at].filter(Boolean).join(' · '), + category: 'Dateiverteilung', + icon: DocumentDuplicateIcon, + href: `${basePath}/dateiverteilung`, + })) + ))] : []), authAPI.getUserProfile(token, controller.signal).then(res => { if (!res.success || !searchText(query, res.data)) return []; return [{ id: 'api-profile', title: 'Dein Profil', subtitle: 'Profildaten', category: 'Profil', icon: UserIcon, href: '/profile' }]; @@ -449,7 +467,7 @@ export default function GlobalSearch({ controller.abort(); if (timerRef.current) clearTimeout(timerRef.current); }; - }, [query, token]); + }, [basePath, hasNativeDateiverteilung, planNavigation, query, token]); // ── Semantic search (parallel to Tier 2) ──────────────────────── diff --git a/src/services/api.ts b/src/services/api.ts index 0b738c5b..a2600d45 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -183,6 +183,7 @@ import { ClassLinksResponse, DateispeicherNodeResponse, DateispeicherSearchResponse, + DateiverteilungResponse, StudyGroupsResponse, NotificationConfigResponse, NotificationPreferences, @@ -798,6 +799,28 @@ export const dateispeicherAPI = { }, }; +// Native Schulportal targeted file-distribution API +export const dateiverteilungAPI = { + async getOverview(token: string, refresh = false, signal?: AbortSignal): Promise { + const response = await apiClient.get('/dateiverteilung', { + headers: { 'X-Session-Token': token }, + params: { refresh }, + signal, + }); + return response.data; + }, + + async downloadFile(token: string, downloadUrl: string, signal?: AbortSignal): Promise { + const response = await apiClient.get('/dateiverteilung/file', { + headers: { 'X-Session-Token': token }, + params: { url: downloadUrl }, + responseType: 'blob', + signal, + }); + return response.data; + }, +}; + // Native Schulportal substitution plan API export const vertretungsplanAPI = { async getPlan(token: string, refresh = false, signal?: AbortSignal): Promise { diff --git a/src/types/index.ts b/src/types/index.ts index 997f0b47..66a9825f 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -839,6 +839,39 @@ export interface DateispeicherSearchResponse { error?: string; } +// Native Schulportal targeted file-distribution types +export interface DateiverteilungFile { + id: string; + name: string; + size?: string; + download_url: string; +} + +export interface DateiverteilungLink { + label: string; + url: string; +} + +export interface DateiverteilungDistribution { + id: string; + title: string; + description?: string; + source: string; + created_at?: string; + unread: boolean; + files: DateiverteilungFile[]; + links: DateiverteilungLink[]; +} + +export interface DateiverteilungResponse { + success: boolean; + distributions: DateiverteilungDistribution[]; + distribution_count: number; + file_count: number; + unread_count: number; + error?: string; +} + // Study-group types export interface StudyGroupTeacher { krz: string; diff --git a/src/utils/moduleCache.ts b/src/utils/moduleCache.ts index 49ebc393..e6681bf5 100644 --- a/src/utils/moduleCache.ts +++ b/src/utils/moduleCache.ts @@ -5,6 +5,7 @@ export type CachedModule = Module; export interface ModuleAvailability { hasDsbModule: boolean; hasNativeDateispeicher: boolean; + hasNativeDateiverteilung: boolean; hasNativeSubstitutionPlan: boolean; hasWahlenModule: boolean; } @@ -12,6 +13,7 @@ export interface ModuleAvailability { export function getModuleAvailability(modules: CachedModule[]): ModuleAvailability { let hasDsbModule = false; let hasNativeDateispeicher = false; + let hasNativeDateiverteilung = false; let hasNativeSubstitutionPlan = false; let hasWahlenModule = false; @@ -22,13 +24,14 @@ export function getModuleAvailability(modules: CachedModule[]): ModuleAvailabili hasDsbModule ||= isDsb; hasNativeDateispeicher ||= links.includes('/dateispeicher.php') || name.includes('dateispeicher'); + hasNativeDateiverteilung ||= links.includes('/dateiverteilung.php') || name.includes('dateiverteilung'); hasNativeSubstitutionPlan ||= !isDsb && ( links.includes('/vertretungsplan.php') || name.includes('vertretungsplan') ); hasWahlenModule ||= links.includes('/oberstufenwahl.php') || name.includes('wahlen'); } - return { hasDsbModule, hasNativeDateispeicher, hasNativeSubstitutionPlan, hasWahlenModule }; + return { hasDsbModule, hasNativeDateispeicher, hasNativeDateiverteilung, hasNativeSubstitutionPlan, hasWahlenModule }; } interface ModuleCacheOwner { diff --git a/src/utils/sidebarNavigation.ts b/src/utils/sidebarNavigation.ts index 71e4bd02..ddf4f12d 100644 --- a/src/utils/sidebarNavigation.ts +++ b/src/utils/sidebarNavigation.ts @@ -4,6 +4,7 @@ export const SIDEBAR_ITEM_IDS = [ 'dashboard', 'messages', 'dateispeicher', + 'dateiverteilung', 'vertretungsplan', 'dsb', 'courses', @@ -22,6 +23,7 @@ export const DEFAULT_SIDEBAR_ORDER: SidebarItemId[] = [ 'dashboard', 'messages', 'dateispeicher', + 'dateiverteilung', 'vertretungsplan', 'dsb', 'courses', @@ -41,6 +43,7 @@ export const SIDEBAR_ITEM_LABELS: Record = { dashboard: 'Dashboard', messages: 'Nachrichten', dateispeicher: 'Dateispeicher', + dateiverteilung: 'Dateiverteilung', vertretungsplan: 'Vertretungsplan', dsb: 'DSBmobile', courses: 'Mein Unterricht',