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:
@@ -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">×</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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
76
frontend/src/lib/features/homework/api/studentHomework.ts
Normal file
76
frontend/src/lib/features/homework/api/studentHomework.ts
Normal 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}`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
34
frontend/src/routes/dashboard/homework/+page.svelte
Normal file
34
frontend/src/routes/dashboard/homework/+page.svelte
Normal file
@@ -0,0 +1,34 @@
|
||||
<script lang="ts">
|
||||
import StudentHomeworkList from '$lib/components/organisms/StudentHomework/StudentHomeworkList.svelte';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Mes devoirs - Classeo</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="page-container">
|
||||
<header class="page-header">
|
||||
<h1>Mes devoirs</h1>
|
||||
</header>
|
||||
|
||||
<StudentHomeworkList />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page-container {
|
||||
max-width: 48rem;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user