feat: Permettre à l'élève de consulter ses devoirs

L'élève n'avait aucun moyen de voir les devoirs assignés à sa classe.
Cette fonctionnalité ajoute la consultation complète : liste triée par
échéance, détail avec pièces jointes, filtrage par matière, et marquage
personnel « fait » en localStorage.

Le dashboard élève affiche désormais les devoirs à venir avec ouverture
du détail en modale, et un lien vers la page complète. L'accès API est
sécurisé par vérification de la classe de l'élève (pas d'IDOR) et
validation du chemin des pièces jointes (pas de path traversal).
This commit is contained in:
2026-03-22 17:01:32 +01:00
parent 14c7849179
commit 2e2328c6ca
20 changed files with 2442 additions and 12 deletions

View File

@@ -1,8 +1,12 @@
<script lang="ts">
import type { DemoData } from '$types';
import type { ScheduleSlot } from '$lib/features/schedule/api/schedule';
import type { StudentHomework, StudentHomeworkDetail } from '$lib/features/homework/api/studentHomework';
import { fetchDaySchedule, fetchNextClass } from '$lib/features/schedule/api/schedule';
import { fetchStudentHomework, fetchHomeworkDetail } from '$lib/features/homework/api/studentHomework';
import HomeworkDetail from '$lib/components/organisms/StudentHomework/HomeworkDetail.svelte';
import { recordSync } from '$lib/features/schedule/stores/scheduleCache.svelte';
import { getHomeworkStatuses } from '$lib/features/homework/stores/homeworkStatus.svelte';
import DashboardSection from '$lib/components/molecules/DashboardSection.svelte';
import SkeletonList from '$lib/components/atoms/Skeleton/SkeletonList.svelte';
import ScheduleWidget from '$lib/components/organisms/StudentSchedule/ScheduleWidget.svelte';
@@ -28,6 +32,16 @@
let scheduleLoading = $state(false);
let scheduleError = $state<string | null>(null);
// Homework widget state
let studentHomeworks = $state<StudentHomework[]>([]);
let homeworkLoading = $state(false);
let hwStatuses = $derived(getHomeworkStatuses());
let pendingHomeworks = $derived(
studentHomeworks.filter(hw => !hwStatuses[hw.id]?.done).slice(0, 5)
);
function formatLocalDate(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
@@ -35,6 +49,11 @@
return `${y}-${m}-${day}`;
}
function formatShortDate(dateStr: string): string {
const date = new Date(dateStr + 'T00:00:00');
return date.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' });
}
async function loadTodaySchedule() {
scheduleLoading = true;
scheduleError = null;
@@ -57,9 +76,47 @@
}
}
if (isEleve) {
loadTodaySchedule();
async function loadHomeworks() {
homeworkLoading = true;
try {
studentHomeworks = await fetchStudentHomework();
} catch {
// Silently fail on dashboard widget
} finally {
homeworkLoading = false;
}
}
// Homework detail modal
let selectedHomeworkDetail = $state<StudentHomeworkDetail | null>(null);
async function openHomeworkDetail(homeworkId: string) {
try {
selectedHomeworkDetail = await fetchHomeworkDetail(homeworkId);
} catch {
// Fallback: navigate to full page
window.location.href = '/dashboard/homework';
}
}
function closeHomeworkDetail() {
selectedHomeworkDetail = null;
}
function handleOverlayClick(e: MouseEvent) {
if (e.target === e.currentTarget) closeHomeworkDetail();
}
function handleModalKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') closeHomeworkDetail();
}
$effect(() => {
if (!isEleve) return;
void loadTodaySchedule();
void loadHomeworks();
});
</script>
<div class="dashboard-student">
@@ -148,11 +205,34 @@
<!-- Devoirs Section -->
<DashboardSection
title="Mes devoirs"
subtitle={hasRealData ? "À faire" : undefined}
isPlaceholder={!hasRealData}
subtitle={isEleve ? "À faire" : (hasRealData ? "À faire" : undefined)}
isPlaceholder={!isEleve && !hasRealData}
placeholderMessage={isMinor ? "Tes devoirs s'afficheront ici" : "Vos devoirs s'afficheront ici"}
>
{#if hasRealData}
{#if isEleve}
{#if homeworkLoading}
<SkeletonList items={3} message="Chargement des devoirs..." />
{:else if pendingHomeworks.length === 0}
<p class="empty-homework">Aucun devoir à faire</p>
{:else}
<ul class="homework-list">
{#each pendingHomeworks as homework}
<li>
<button class="homework-item" style:border-left-color={homework.subjectColor ?? '#3b82f6'} onclick={() => openHomeworkDetail(homework.id)}>
<div class="homework-header">
<span class="homework-subject" style:color={homework.subjectColor ?? '#3b82f6'}>{homework.subjectName}</span>
</div>
<span class="homework-title">{homework.title}</span>
<span class="homework-due">Pour le {formatShortDate(homework.dueDate)}</span>
</button>
</li>
{/each}
</ul>
<a href="/dashboard/homework" class="view-all-link">
Voir tous les devoirs →
</a>
{/if}
{:else if hasRealData}
{#if isLoading}
<SkeletonList items={3} message="Chargement des devoirs..." />
{:else}
@@ -178,6 +258,16 @@
</div>
</div>
{#if selectedHomeworkDetail}
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div class="homework-modal-overlay" onclick={handleOverlayClick} onkeydown={handleModalKeydown} role="presentation">
<div class="homework-modal" role="dialog" aria-modal="true" aria-label="Détail du devoir">
<button class="homework-modal-close" onclick={closeHomeworkDetail} aria-label="Fermer">&times;</button>
<HomeworkDetail detail={selectedHomeworkDetail} onBack={closeHomeworkDetail} />
</div>
</div>
{/if}
<style>
.dashboard-student {
display: flex;
@@ -327,10 +417,21 @@
}
.homework-item {
display: block;
width: 100%;
padding: 0.75rem;
background: #f9fafb;
border: none;
border-radius: 0.5rem;
border-left: 3px solid #3b82f6;
text-align: left;
cursor: pointer;
transition: background 0.15s;
font: inherit;
}
button.homework-item:hover {
background: #f3f4f6;
}
.homework-item.done {
@@ -380,4 +481,67 @@
font-size: 0.875rem;
color: #6b7280;
}
.empty-homework {
margin: 0;
text-align: center;
padding: 1rem;
color: #6b7280;
font-size: 0.875rem;
}
.view-all-link {
display: block;
text-align: center;
margin-top: 0.75rem;
color: #3b82f6;
font-size: 0.875rem;
font-weight: 500;
text-decoration: none;
}
.view-all-link:hover {
color: #2563eb;
}
/* Homework detail modal */
.homework-modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 50;
padding: 1rem;
}
.homework-modal {
position: relative;
background: white;
border-radius: 0.75rem;
padding: 1.5rem;
max-width: 40rem;
width: 100%;
max-height: 80vh;
overflow-y: auto;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
.homework-modal-close {
position: absolute;
top: 0.75rem;
right: 0.75rem;
background: none;
border: none;
font-size: 1.5rem;
color: #6b7280;
cursor: pointer;
line-height: 1;
padding: 0.25rem;
}
.homework-modal-close:hover {
color: #1f2937;
}
</style>

View File

@@ -0,0 +1,181 @@
<script lang="ts">
import type { StudentHomework } from '$lib/features/homework/api/studentHomework';
let {
homework,
isDone = false,
onToggleDone,
onclick
}: {
homework: StudentHomework;
isDone?: boolean;
onToggleDone?: (id: string) => void;
onclick?: (id: string) => void;
} = $props();
function formatDueDate(dateStr: string): string {
const date = new Date(dateStr + 'T00:00:00');
return date.toLocaleDateString('fr-FR', {
weekday: 'short',
day: 'numeric',
month: 'long'
});
}
function handleToggle(e: Event) {
e.stopPropagation();
onToggleDone?.(homework.id);
}
function handleClick() {
onclick?.(homework.id);
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleClick();
}
}
</script>
<div
class="homework-card"
class:done={isDone}
style:border-left-color={homework.subjectColor ?? '#3b82f6'}
role="button"
tabindex="0"
onclick={handleClick}
onkeydown={handleKeydown}
>
<div class="card-header">
<span class="subject-name" style:color={homework.subjectColor ?? '#3b82f6'}>
{homework.subjectName}
</span>
<button
class="toggle-done"
class:checked={isDone}
onclick={handleToggle}
aria-label={isDone ? `Marquer "${homework.title}" comme à faire` : `Marquer "${homework.title}" comme fait`}
>
{#if isDone}
<svg width="18" height="18" viewBox="0 0 18 18" fill="none">
<rect x="1" y="1" width="16" height="16" rx="3" fill="#22c55e" stroke="#22c55e" stroke-width="2"/>
<path d="M5 9l3 3 5-6" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
{:else}
<svg width="18" height="18" viewBox="0 0 18 18" fill="none">
<rect x="1" y="1" width="16" height="16" rx="3" stroke="#d1d5db" stroke-width="2"/>
</svg>
{/if}
</button>
</div>
<h3 class="card-title">{homework.title}</h3>
<div class="card-footer">
<span class="due-date">Pour le {formatDueDate(homework.dueDate)}</span>
<span class="status-badge" class:done={isDone}>
{isDone ? 'Fait' : 'À faire'}
</span>
{#if homework.hasAttachments}
<span class="attachment-indicator" title="Pièce(s) jointe(s)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48"/>
</svg>
</span>
{/if}
</div>
</div>
<style>
.homework-card {
padding: 0.75rem;
background: #f9fafb;
border-radius: 0.5rem;
border-left: 3px solid #3b82f6;
cursor: pointer;
transition: background 0.15s, box-shadow 0.15s;
}
.homework-card:hover {
background: #f3f4f6;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.homework-card:focus-visible {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
.homework-card.done {
opacity: 0.6;
border-left-color: #22c55e !important;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.25rem;
}
.subject-name {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
.toggle-done {
background: none;
border: none;
padding: 0.125rem;
cursor: pointer;
display: flex;
align-items: center;
}
.card-title {
margin: 0 0 0.25rem;
font-size: 0.9375rem;
font-weight: 500;
color: #1f2937;
}
.homework-card.done .card-title {
text-decoration: line-through;
color: #6b7280;
}
.card-footer {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.due-date {
font-size: 0.8125rem;
color: #6b7280;
}
.status-badge {
font-size: 0.75rem;
font-weight: 600;
color: #92400e;
background: #fef3c7;
border-radius: 999px;
padding: 0.125rem 0.5rem;
}
.status-badge.done {
color: #166534;
background: #dcfce7;
}
.attachment-indicator {
color: #6b7280;
display: flex;
align-items: center;
}
</style>

View File

@@ -0,0 +1,238 @@
<script lang="ts">
import type { StudentHomeworkDetail } from '$lib/features/homework/api/studentHomework';
import { getAttachmentUrl } from '$lib/features/homework/api/studentHomework';
import { authenticatedFetch } from '$lib/auth';
let {
detail,
onBack
}: {
detail: StudentHomeworkDetail;
onBack: () => void;
} = $props();
function formatDueDate(dateStr: string): string {
const date = new Date(dateStr + 'T00:00:00');
return date.toLocaleDateString('fr-FR', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric'
});
}
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} o`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} Ko`;
return `${(bytes / (1024 * 1024)).toFixed(1)} Mo`;
}
function shouldOpenInline(mimeType: string): boolean {
return mimeType === 'application/pdf' || mimeType.startsWith('image/') || mimeType.startsWith('text/');
}
function triggerBlobNavigation(blobUrl: string, filename: string, inline: boolean): void {
const link = document.createElement('a');
link.href = blobUrl;
if (inline) {
link.target = '_blank';
link.rel = 'noopener noreferrer';
} else {
link.download = filename;
}
link.click();
setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000);
}
let downloadError = $state<string | null>(null);
async function downloadAttachment(attachmentId: string, filename: string, mimeType: string) {
downloadError = null;
const url = getAttachmentUrl(detail.id, attachmentId);
try {
const response = await authenticatedFetch(url);
if (!response.ok) {
downloadError = `Impossible de télécharger "${filename}".`;
return;
}
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
triggerBlobNavigation(blobUrl, filename, shouldOpenInline(mimeType));
} catch {
downloadError = `Impossible de télécharger "${filename}".`;
}
}
</script>
<div class="homework-detail">
<button class="back-button" onclick={onBack}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 12H5M12 19l-7-7 7-7"/>
</svg>
Retour
</button>
<header class="detail-header" style:border-left-color={detail.subjectColor ?? '#3b82f6'}>
<span class="subject-name" style:color={detail.subjectColor ?? '#3b82f6'}>
{detail.subjectName}
</span>
<h2 class="detail-title">{detail.title}</h2>
<div class="detail-meta">
<span class="due-date">Pour le {formatDueDate(detail.dueDate)}</span>
<span class="teacher-name">Par {detail.teacherName}</span>
</div>
</header>
{#if detail.description}
<section class="detail-description">
<h3>Description</h3>
<p>{detail.description}</p>
</section>
{/if}
{#if detail.attachments.length > 0}
<section class="detail-attachments">
<h3>Pièces jointes</h3>
{#if downloadError}
<p class="download-error" role="alert">{downloadError}</p>
{/if}
<ul class="attachments-list">
{#each detail.attachments as attachment}
<li>
<button
class="attachment-item"
onclick={() =>
downloadAttachment(attachment.id, attachment.filename, attachment.mimeType)}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48"/>
</svg>
<span class="attachment-name">{attachment.filename}</span>
<span class="attachment-size">{formatFileSize(attachment.fileSize)}</span>
</button>
</li>
{/each}
</ul>
</section>
{/if}
</div>
<style>
.homework-detail {
display: flex;
flex-direction: column;
gap: 1rem;
}
.back-button {
display: inline-flex;
align-items: center;
gap: 0.375rem;
background: none;
border: none;
color: #3b82f6;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
padding: 0.25rem 0;
align-self: flex-start;
}
.back-button:hover {
color: #2563eb;
}
.detail-header {
border-left: 4px solid #3b82f6;
padding-left: 1rem;
}
.subject-name {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
.detail-title {
margin: 0.25rem 0 0.5rem;
font-size: 1.25rem;
font-weight: 600;
color: #1f2937;
}
.detail-meta {
display: flex;
flex-direction: column;
gap: 0.125rem;
font-size: 0.875rem;
color: #6b7280;
}
.detail-description h3,
.detail-attachments h3 {
margin: 0 0 0.5rem;
font-size: 0.9375rem;
font-weight: 600;
color: #374151;
}
.detail-description p {
margin: 0;
color: #4b5563;
line-height: 1.6;
white-space: pre-wrap;
}
.download-error {
margin: 0 0 0.5rem;
padding: 0.5rem 0.75rem;
background: #fee2e2;
border-radius: 0.375rem;
color: #991b1b;
font-size: 0.8125rem;
}
.attachments-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.attachment-item {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.625rem 0.75rem;
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 0.375rem;
cursor: pointer;
text-align: left;
font-size: 0.875rem;
}
.attachment-item:hover {
background: #f3f4f6;
border-color: #d1d5db;
}
.attachment-name {
flex: 1;
color: #3b82f6;
font-weight: 500;
}
.attachment-size {
color: #9ca3af;
font-size: 0.75rem;
}
</style>

View File

@@ -0,0 +1,277 @@
<script lang="ts">
import type { StudentHomework, StudentHomeworkDetail as HomeworkDetailType } from '$lib/features/homework/api/studentHomework';
import { fetchStudentHomework, fetchHomeworkDetail } from '$lib/features/homework/api/studentHomework';
import { toggleHomeworkDone, getHomeworkStatuses } from '$lib/features/homework/stores/homeworkStatus.svelte';
import { isOffline } from '$lib/features/schedule/stores/scheduleCache.svelte';
import HomeworkCard from './HomeworkCard.svelte';
import HomeworkDetail from './HomeworkDetail.svelte';
import SkeletonList from '$lib/components/atoms/Skeleton/SkeletonList.svelte';
let homeworks = $state<StudentHomework[]>([]);
let allSubjects = $state<{ id: string; name: string; color: string | null }[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
let selectedSubjectId = $state<string | null>(null);
let selectedDetail = $state<HomeworkDetailType | null>(null);
let detailLoading = $state(false);
let statuses = $derived(getHomeworkStatuses());
let pendingHomeworks = $derived(
homeworks.filter(hw => !statuses[hw.id]?.done)
);
let doneHomeworks = $derived(
homeworks.filter(hw => statuses[hw.id]?.done)
);
function extractSubjects(hws: StudentHomework[]): { id: string; name: string; color: string | null }[] {
const map = new Map<string, { id: string; name: string; color: string | null }>();
for (const hw of hws) {
if (!map.has(hw.subjectId)) {
map.set(hw.subjectId, { id: hw.subjectId, name: hw.subjectName, color: hw.subjectColor });
}
}
return Array.from(map.values());
}
async function loadHomeworks() {
loading = true;
error = null;
try {
homeworks = await fetchStudentHomework(selectedSubjectId ?? undefined);
if (selectedSubjectId === null) {
allSubjects = extractSubjects(homeworks);
}
} catch (e) {
error = e instanceof Error ? e.message : 'Erreur de chargement';
} finally {
loading = false;
}
}
function handleFilterChange(subjectId: string | null) {
selectedSubjectId = subjectId;
}
async function handleCardClick(homeworkId: string) {
detailLoading = true;
try {
selectedDetail = await fetchHomeworkDetail(homeworkId);
} catch {
// Stay on list if detail fails
} finally {
detailLoading = false;
}
}
function handleBack() {
selectedDetail = null;
}
function handleToggleDone(homeworkId: string) {
toggleHomeworkDone(homeworkId);
}
$effect(() => {
void selectedSubjectId;
void loadHomeworks();
});
</script>
{#if selectedDetail}
<HomeworkDetail detail={selectedDetail} onBack={handleBack} />
{:else}
<div class="student-homework">
{#if isOffline()}
<div class="offline-banner" role="status">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="1" y1="1" x2="23" y2="23"/>
<path d="M16.72 11.06A10.94 10.94 0 0 1 19 12.55"/>
<path d="M5 12.55a10.94 10.94 0 0 1 5.17-2.39"/>
<path d="M10.71 5.05A16 16 0 0 1 22.56 9"/>
<path d="M1.42 9a15.91 15.91 0 0 1 4.7-2.88"/>
<path d="M8.53 16.11a6 6 0 0 1 6.95 0"/>
<line x1="12" y1="20" x2="12.01" y2="20"/>
</svg>
Mode hors ligne
</div>
{/if}
{#if allSubjects.length > 1}
<div class="filter-bar" role="toolbar" aria-label="Filtrer par matière">
<button
class="filter-chip"
class:active={selectedSubjectId === null}
onclick={() => handleFilterChange(null)}
>
Tous
</button>
{#each allSubjects as subject}
<button
class="filter-chip"
class:active={selectedSubjectId === subject.id}
style:--chip-color={subject.color ?? '#3b82f6'}
onclick={() => handleFilterChange(subject.id)}
>
{subject.name}
</button>
{/each}
</div>
{/if}
{#if loading}
<SkeletonList items={4} message="Chargement des devoirs..." />
{:else if error}
<div class="error-message" role="alert">
<p>{error}</p>
<button onclick={() => void loadHomeworks()}>Réessayer</button>
</div>
{:else if homeworks.length === 0}
<div class="empty-state">
<p>Aucun devoir pour le moment</p>
</div>
{:else}
{#if pendingHomeworks.length > 0}
<section>
<h3 class="section-title">À faire ({pendingHomeworks.length})</h3>
<ul class="homework-list" role="list">
{#each pendingHomeworks as hw (hw.id)}
<li>
<HomeworkCard
homework={hw}
isDone={false}
onToggleDone={handleToggleDone}
onclick={handleCardClick}
/>
</li>
{/each}
</ul>
</section>
{/if}
{#if doneHomeworks.length > 0}
<section>
<h3 class="section-title">Terminés ({doneHomeworks.length})</h3>
<ul class="homework-list" role="list">
{#each doneHomeworks as hw (hw.id)}
<li>
<HomeworkCard
homework={hw}
isDone={true}
onToggleDone={handleToggleDone}
onclick={handleCardClick}
/>
</li>
{/each}
</ul>
</section>
{/if}
{/if}
{#if detailLoading}
<div class="detail-loading-overlay" role="status">
<p>Chargement...</p>
</div>
{/if}
</div>
{/if}
<style>
.student-homework {
display: flex;
flex-direction: column;
gap: 1rem;
}
.offline-banner {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: #fef3c7;
border: 1px solid #f59e0b;
border-radius: 0.5rem;
color: #92400e;
font-size: 0.8125rem;
font-weight: 500;
}
.filter-bar {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.filter-chip {
padding: 0.375rem 0.75rem;
border: 1px solid #e5e7eb;
border-radius: 1rem;
background: white;
font-size: 0.8125rem;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.filter-chip:hover {
background: #f3f4f6;
}
.filter-chip.active {
background: var(--chip-color, #3b82f6);
color: white;
border-color: var(--chip-color, #3b82f6);
}
.section-title {
margin: 0 0 0.5rem;
font-size: 0.875rem;
font-weight: 600;
color: #6b7280;
text-transform: uppercase;
letter-spacing: 0.025em;
}
.homework-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.empty-state {
text-align: center;
padding: 2rem;
color: #6b7280;
}
.error-message {
text-align: center;
padding: 1rem;
color: #ef4444;
}
.error-message button {
margin-top: 0.5rem;
padding: 0.375rem 0.75rem;
border: 1px solid #ef4444;
border-radius: 0.375rem;
background: white;
color: #ef4444;
cursor: pointer;
}
.detail-loading-overlay {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.8);
z-index: 50;
}
</style>

View File

@@ -0,0 +1,76 @@
import { getApiBaseUrl } from '$lib/api';
import { authenticatedFetch } from '$lib/auth';
export interface StudentHomework {
id: string;
subjectId: string;
subjectName: string;
subjectColor: string | null;
teacherId: string;
teacherName: string;
title: string;
description: string | null;
dueDate: string;
createdAt: string;
hasAttachments: boolean;
}
export interface HomeworkAttachment {
id: string;
filename: string;
fileSize: number;
mimeType: string;
}
export interface StudentHomeworkDetail {
id: string;
subjectId: string;
subjectName: string;
subjectColor: string | null;
teacherId: string;
teacherName: string;
title: string;
description: string | null;
dueDate: string;
createdAt: string;
attachments: HomeworkAttachment[];
}
/**
* Récupère la liste des devoirs pour l'élève connecté.
*/
export async function fetchStudentHomework(subjectId?: string): Promise<StudentHomework[]> {
const apiUrl = getApiBaseUrl();
const params = subjectId ? `?subjectId=${encodeURIComponent(subjectId)}` : '';
const response = await authenticatedFetch(`${apiUrl}/me/homework${params}`);
if (!response.ok) {
throw new Error(`Erreur lors du chargement des devoirs (${response.status})`);
}
const json = await response.json();
return json.data ?? [];
}
/**
* Récupère le détail d'un devoir.
*/
export async function fetchHomeworkDetail(homeworkId: string): Promise<StudentHomeworkDetail> {
const apiUrl = getApiBaseUrl();
const response = await authenticatedFetch(`${apiUrl}/me/homework/${homeworkId}`);
if (!response.ok) {
throw new Error(`Erreur lors du chargement du devoir (${response.status})`);
}
const json = await response.json();
return json.data;
}
/**
* Retourne l'URL de téléchargement d'une pièce jointe.
*/
export function getAttachmentUrl(homeworkId: string, attachmentId: string): string {
const apiUrl = getApiBaseUrl();
return `${apiUrl}/me/homework/${homeworkId}/attachments/${attachmentId}`;
}

View File

@@ -0,0 +1,60 @@
import { browser } from '$app/environment';
const STORAGE_KEY = 'classeo:homework:status';
interface HomeworkStatusEntry {
done: boolean;
doneAt: number | null;
}
type StatusMap = Record<string, HomeworkStatusEntry>;
function loadStatuses(): StatusMap {
if (!browser) return {};
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? (JSON.parse(raw) as StatusMap) : {};
} catch {
return {};
}
}
function saveStatuses(statuses: StatusMap): void {
if (!browser) return;
localStorage.setItem(STORAGE_KEY, JSON.stringify(statuses));
}
let statuses = $state<StatusMap>(loadStatuses());
/**
* Marque un devoir comme "fait" ou "à faire".
*/
export function toggleHomeworkDone(homeworkId: string): void {
const current = statuses[homeworkId];
const isDone = current?.done ?? false;
statuses = {
...statuses,
[homeworkId]: {
done: !isDone,
doneAt: !isDone ? Date.now() : null,
},
};
saveStatuses(statuses);
}
/**
* Vérifie si un devoir est marqué comme "fait".
*/
export function isHomeworkDone(homeworkId: string): boolean {
return statuses[homeworkId]?.done ?? false;
}
/**
* Retourne le map complet des statuts (réactif via $state).
*/
export function getHomeworkStatuses(): StatusMap {
return statuses;
}