diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..c7799ec --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,595 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Mini-Maxit Frontend is a **Svelte 5 + SvelteKit 2** application for a programming contest platform. It communicates with a backend API and file storage service. + +**Tech Stack:** + +- Svelte 5 with runes (`$state`, `$derived`, `$effect`, `$props`) +- SvelteKit 2 with Node.js adapter (Docker deployment) +- TypeScript (strict mode) +- Tailwind CSS 4 (OKLCH color space) +- Paraglide.js for compile-time i18n (English/Polish) +- Valibot for schema validation +- pnpm (required - DO NOT use npm/yarn) + +## Quick Start Commands + +### Development + +```bash +pnpm install # Install dependencies (pnpm ONLY) +pnpm run dev # Start dev server with HMR +pnpm run build # Production build +pnpm run preview # Preview built app +``` + +### Code Quality + +```bash +pnpm run check # Type check with svelte-check +pnpm run check:watch # Watch mode type checking +pnpm run lint # Prettier + ESLint +pnpm run format # Auto-format with Prettier +``` + +### Environment Setup + +```bash +cp .env.example .env # Create environment file +# Edit .env with BACKEND_API_URL and FILE_STORAGE_URL +``` + +### Docker + +```bash +docker build -t maxit-frontend . +docker run -p 3000:3000 \ + -e BACKEND_API_URL=http://backend:8000 \ + -e FILE_STORAGE_URL=http://storage:8888 \ + maxit-frontend +``` + +## Critical Architecture Patterns + +### 1. Remote Functions (Primary Server Pattern) + +This project uses **SvelteKit remote functions** (`experimental.remoteFunctions: true`) instead of traditional `+page.server.ts` load functions. + +**Three types of remote functions:** + +#### `query` - Server-side data fetching + +```typescript +// tasks.remote.ts +import { query, getRequestEvent } from '$app/server'; +import * as v from 'valibot'; + +// Without parameters +export const getTasks = query(async () => { + const { cookies } = getRequestEvent(); + const taskService = createTaskService(cookies); + return await taskService.getAllTasks(); +}); + +// With parameters (requires Valibot schema) +export const getTask = query(v.number(), async (taskId: number) => { + const { cookies } = getRequestEvent(); + const taskService = createTaskService(cookies); + return await taskService.getTaskById(taskId); +}); +``` + +Usage in component: + +```svelte + + +{#if tasksQuery.error} + tasksQuery.refresh()} /> +{:else if tasksQuery.loading} + +{:else if tasksQuery.current} + +{/if} +``` + +#### `form` - Form submissions with progressive enhancement + +```typescript +// login.remote.ts +import { form, getRequestEvent } from '$app/server'; +import * as v from 'valibot'; + +export const login = form( + v.object({ + email: v.pipe(v.string(), v.email()), + password: v.pipe(v.string(), v.nonEmpty()) + }), + async (data) => { + const { cookies } = getRequestEvent(); + // Handle login logic + return { success: true }; + } +); +``` + +Usage: + +```svelte + + +
+ + +
+``` + +#### `command` - Programmatic mutations + +```typescript +export const approveRequest = command( + v.object({ + contestId: v.pipe(v.number(), v.integer()), + userId: v.pipe(v.number(), v.integer()) + }), + async (data) => { + const { cookies } = getRequestEvent(); + const contestService = createContestService(cookies); + await contestService.approveRegistrationRequest(data.contestId, data.userId); + return { success: true }; + } +); +``` + +Usage (only from event handlers): + +```svelte + +``` + +### 2. Service Layer Pattern + +**All API communication goes through service classes.** Never make direct fetch calls. + +Services are located in `/src/lib/services/`: + +- `ApiService.ts` - Base HTTP client with auth & token refresh +- `AuthService.ts` - Authentication operations +- `TaskService.ts` - Task management +- `ContestService.ts` - Contest operations +- `SubmissionService.ts` - Code submission handling +- `UserService.ts` - User profile management +- `AccessControlService.ts` - Access control operations +- `TasksManagementService.ts` - Admin task management +- `ContestsManagementService.ts` - Admin contest management + +**Service pattern:** + +```typescript +import type { ApiService } from './ApiService'; +import type { Task } from '$lib/dto/task'; + +export class TaskService { + constructor(private apiClient: ApiService) {} + + async getAllTasks(): Promise<{ + success: boolean; + status: number; + data?: Task[]; + error?: string; + }> { + try { + const response = await this.apiClient.get>({ + url: '/tasks/' + }); + return { success: true, data: response.data, status: 200 }; + } catch (error) { + if (error instanceof ApiError) { + return { + success: false, + error: error.getApiMessage(), + status: error.getStatus() + }; + } + throw error; + } + } +} + +// Factory function for dependency injection +export function createTaskService(cookies: Cookies): TaskService { + const apiClient = createApiClient(cookies); + return new TaskService(apiClient); +} +``` + +**Using services in remote functions:** + +```typescript +import { createTaskService } from '$lib/services/TaskService'; +import { getRequestEvent } from '$app/server'; + +const { cookies } = getRequestEvent(); +const taskService = createTaskService(cookies); +``` + +### 3. Svelte 5 Runes (No Legacy Syntax) + +```svelte + + + +``` + +**Async Components:** Enabled via `experimental.async: true` - can use top-level `await` in components. + +### 4. Authentication & Security + +**Token Storage:** HTTP-only cookies (XSS-resistant) + +```typescript +// src/lib/token.ts +export function setAccessToken(cookies: Cookies, token: string): void { + cookies.set(ACCESS_TOKEN_KEY, token, { + path: '/', + httpOnly: true, + secure: true, + sameSite: 'strict', + maxAge: 60 * 60 * 24 * 7 // 7 days + }); +} +``` + +**Server-side auth hook:** `src/hooks.server.ts` + +- Decodes JWT from cookie +- Populates `event.locals.user` +- Redirects unauthenticated users from protected routes +- Handles i18n middleware via Paraglide + +**Token refresh:** Automatic in `ApiService` on 401 responses + +## Project Structure + +``` +src/ +├── routes/ # File-based routing +│ ├── (landing)/ # Route group: login, register +│ │ ├── login/ +│ │ │ ├── +page.svelte +│ │ │ └── login.remote.ts +│ │ └── register/ +│ ├── dashboard/ # Authenticated area +│ │ ├── admin/ # Admin-only routes +│ │ ├── teacher/ # Teacher routes +│ │ ├── user/ # User-specific routes +│ │ ├── tasks/ +│ │ │ ├── [taskId]/ +│ │ │ │ ├── +page.svelte +│ │ │ │ ├── task.remote.ts +│ │ │ │ └── submit.remote.ts +│ │ │ ├── +page.svelte +│ │ │ └── tasks.remote.ts +│ │ ├── contests/ +│ │ ├── +layout.svelte # Dashboard layout +│ │ └── +layout.server.ts +│ ├── +layout.svelte # Root layout +│ ├── +page.svelte # Landing page +│ └── +error.svelte # Error boundary +│ +├── lib/ +│ ├── components/ +│ │ ├── ui/ # Primitive components (Button, Card, etc.) +│ │ ├── common/ # Shared components (LoadingSpinner, ErrorCard) +│ │ ├── dashboard/ # Feature-specific dashboard components +│ │ │ ├── admin/ +│ │ │ ├── tasks/ +│ │ │ ├── contests/ +│ │ │ ├── DashboardSidebar.svelte +│ │ │ └── utils.ts # Dashboard utilities (title translations) +│ │ └── landing_page/ # Landing page sections +│ │ +│ ├── services/ # Business logic layer +│ │ ├── ApiService.ts # Base HTTP client +│ │ ├── TaskService.ts +│ │ ├── ContestService.ts +│ │ ├── AuthService.ts +│ │ └── index.ts +│ │ +│ ├── dto/ # TypeScript type definitions +│ │ ├── task.ts +│ │ ├── contest.ts +│ │ ├── response.ts +│ │ └── error.ts +│ │ +│ ├── paraglide/ # Generated i18n (DO NOT EDIT) +│ ├── routes.ts # Route constants & helpers +│ ├── token.ts # Token management +│ ├── jwt.ts # JWT decoding +│ └── utils.ts # Utility functions +│ +├── hooks.server.ts # Server middleware (auth, i18n) +├── app.css # Global styles +└── app.d.ts # Global TypeScript types +``` + +**Component hierarchy:** + +``` +ui/ (primitives) → common/ (shared) → dashboard/ (features) → routes/ (pages) +``` + +Components at higher levels can import from lower levels, but not vice versa. + +## Internationalization (i18n) + +Uses **Paraglide** for compile-time i18n (zero runtime overhead). + +**Message files:** `/messages/en.json` and `/messages/pl.json` + +**ALWAYS add translations to BOTH files:** + +```json +// messages/en.json +{ + "admin_tasks_title": "Task Management", + "validation_email_required": "Email is required" +} + +// messages/pl.json +{ + "admin_tasks_title": "Zarządzanie zadaniami", + "validation_email_required": "Email jest wymagany" +} +``` + +**Using in components:** + +```svelte + + +

{m.admin_tasks_title()}

+ + +

{m.hello_world({ name: 'User' })}

+ + +v.pipe( v.string(m.validation_email_required()), v.email(m.validation_email_invalid()) ) +``` + +**Dashboard page titles:** Managed in `/src/lib/components/dashboard/utils.ts` via `getDashboardTitleTranslationFromPathname()`. Add new routes to `routeTitleMap` or add pattern matching for dynamic routes. + +## Styling Guidelines + +**Tailwind CSS 4 with OKLCH colors:** + +```svelte + +
+

{m.title()}

+
+ + +
+ +
+``` + +**CSS variables for theming:** + +- Primary: `oklch(0.3879 0.0851 237.33)` (deep blue) +- Secondary: `oklch(0.4725 0.0809 206.34)` (lighter blue) +- Background: `oklch(0.96 0.01 79.34)` (off-white) +- Dark mode: Automatically switches via `.dark` class + +**Always reuse common components:** + +```svelte +import {(LoadingSpinner, ErrorCard, EmptyState)} from '$lib/components/common'; + + + query.refresh()} /> + +``` + +## TypeScript Conventions + +**Strict mode enabled.** Configuration in `tsconfig.json`: + +- Path aliases: `$lib` → `/src/lib`, `$routes` → `/src/routes` +- ES modules only + +**Type patterns:** + +```typescript +// Component props +interface Props { + title: string; + count?: number; + onClick?: () => void; +} + +// DTOs in /src/lib/dto/ +export interface Task { + id: number; + title: string; + createdAt: string; +} + +// Service responses +type ServiceResponse = { + success: boolean; + status: number; + data?: T; + error?: string; +}; +``` + +**Validation with Valibot:** + +```typescript +import * as v from 'valibot'; +import * as m from '$lib/paraglide/messages'; + +const FormSchema = v.object({ + email: v.pipe(v.string(m.validation_email_required()), v.email(m.validation_email_invalid())), + password: v.pipe( + v.string(m.validation_password_required()), + v.minLength(8, m.validation_password_min_length()) + ) +}); + +type FormData = v.InferOutput; +``` + +## File Naming Conventions + +| Type | Convention | Example | +| ---------------- | ----------------------------- | ----------------------------------- | +| Components | PascalCase | `TaskDescription.svelte` | +| Services | PascalCase + `Service` suffix | `TaskService.ts` | +| DTOs | lowercase | `task.ts`, `contest.ts` | +| Routes | SvelteKit convention | `+page.svelte`, `+layout.server.ts` | +| Remote functions | `.remote.ts` suffix | `tasks.remote.ts` | +| Utilities | camelCase | `calculateScore.ts` | + +## Common Patterns + +### Query with Loading/Error States + +```svelte + + +{#if query.error} + query.refresh()} /> +{:else if query.loading} + +{:else if query.current} +
{query.current.title}
+{/if} +``` + +### Form Submission + +```svelte + + +
+ + +
+``` + +### Dialog Pattern + +```svelte + + + + query.refresh()} /> +``` + +## Code Quality Checklist + +Before committing: + +- [ ] `pnpm run check` passes (no TypeScript errors) +- [ ] `pnpm run lint` passes (code formatted) +- [ ] No console.log/console.error in production code +- [ ] All user-facing strings use i18n (added to both en.json and pl.json) +- [ ] Responsive design tested (mobile + desktop) +- [ ] Loading and error states handled +- [ ] Services used for all API calls (no direct fetch) + +## Important Notes + +1. **Always use pnpm** - NEVER use npm or yarn (will create wrong lockfiles) +2. **Always use remote functions** for server-side operations +3. **Always use services** for API calls - never direct fetch +4. **Always add i18n to BOTH languages** - check existing messages first +5. **Use Svelte 5 runes** - no legacy reactive syntax (`$:`, stores unless necessary) +6. **Match existing design** - refer to landing page, admin pages, and task pages +7. **Validate with Valibot** - especially for user input and form schemas +8. **HTTP-only cookies for tokens** - never use localStorage/sessionStorage + +## Environment Variables + +Required in `.env`: + +```bash +BACKEND_API_URL=http://localhost:8000 +FILE_STORAGE_URL=http://file-storage:8888 +``` + +## Development Workflow + +**Commit convention:** Use [Conventional Commits](https://www.conventionalcommits.org/) + +```bash +feat: add contest leaderboard component +fix: correct timezone conversion on client +docs: update API integration guide +refactor: simplify auth service +``` + +**Pre-commit hooks:** Automatically run syntax checks, trailing whitespace removal, YAML validation + +**Main branch:** `master` + +## Key Reference Files + +- Remote function examples: `/src/routes/dashboard/tasks/tasks.remote.ts` +- Form example: `/src/routes/(landing)/login/login.remote.ts` +- Service example: `/src/lib/services/TaskService.ts` +- API client: `/src/lib/services/ApiService.ts` +- Landing page styling: `/src/routes/+page.svelte` +- Dashboard layout: `/src/routes/dashboard/+layout.svelte` +- Auth middleware: `/src/hooks.server.ts` diff --git a/messages/en.json b/messages/en.json index 7765dbf..f5bad25 100644 --- a/messages/en.json +++ b/messages/en.json @@ -739,5 +739,135 @@ "contest_user_stats_task_failed": "Failed", "contest_user_stats_task_not_attempted": "Not Attempted", "contest_user_stats_expand_details": "Click to expand task breakdown", - "contest_user_stats_collapse_details": "Click to collapse task breakdown" + "contest_user_stats_collapse_details": "Click to collapse task breakdown", + "groups_management_title": "Group Management", + "groups_all_groups": "All Groups", + "groups_loading": "Loading groups...", + "groups_load_error_title": "Failed to load groups", + "groups_no_groups_title": "No groups yet", + "groups_no_groups_description": "Create your first group to get started", + "groups_create_title": "Create Group", + "groups_create_description": "Create a new user group", + "groups_create_dialog_title": "Create New Group", + "groups_create_dialog_description": "Enter a name for the new group.", + "groups_create_success": "Group created successfully!", + "groups_create_error": "Failed to create group", + "groups_edit_button": "Edit Group", + "groups_edit_dialog_title": "Edit Group", + "groups_edit_dialog_description": "Update the group name.", + "groups_edit_success": "Group updated successfully!", + "groups_edit_error": "Failed to update group", + "groups_form_name_label": "Group Name", + "groups_form_name_placeholder": "e.g., CS101 Spring 2024", + "groups_form_name_required": "Group name is required", + "groups_form_name_min_length": "Group name must be at least 3 characters", + "groups_form_name_max_length": "Group name must be at most 50 characters", + "groups_form_cancel": "Cancel", + "groups_form_create": "Create Group", + "groups_form_update": "Update Group", + "group_card_id": "ID", + "group_card_created": "Created", + "group_card_members": "Members", + "group_card_view_details": "View Details", + "group_details_subtitle": "Group #{groupId} Details", + "group_members_title": "Group Members", + "group_members_loading": "Loading members...", + "group_members_load_error": "Failed to load group members", + "group_members_no_members_title": "No members", + "group_members_no_members_description": "This group has no members yet", + "group_members_add_title": "Add Users to Group", + "group_members_add_description": "Add new members to this group", + "group_members_add_dialog_title": "Add Users", + "group_members_add_dialog_description": "Select users to add to this group.", + "group_members_add_search_label": "Search Users", + "group_members_add_search_placeholder": "Search by username or email...", + "group_members_add_select_all": "Select All", + "group_members_add_deselect_all": "Deselect All", + "group_members_add_selected_count": "{count} selected", + "group_members_add_cancel": "Cancel", + "group_members_add_submit": "Add Users", + "group_members_add_success": "Users added successfully!", + "group_members_add_error": "Failed to add users", + "group_members_users_load_error": "Failed to load users", + "group_members_no_users_found": "No users found", + "group_members_remove_title": "Remove User", + "group_members_remove_confirm_title": "Confirm Removal", + "group_members_remove_confirm_description": "Are you sure you want to remove {userName} from this group? This action cannot be undone.", + "group_members_remove_confirm": "Remove", + "group_members_remove_cancel": "Cancel", + "group_members_remove_success": "User removed successfully!", + "group_members_remove_error": "Failed to remove user", + "group_manage_collaborators_title": "Manage Collaborators", + "group_manage_collaborators_description": "Control who can manage this group", + "group_collaborators_title": "Group Collaborators", + "group_collaborators_page_title": "Collaborators for Group #{groupId}", + "group_collaborators_loading": "Loading collaborators...", + "group_collaborators_load_error": "Failed to load collaborators", + "group_collaborators_no_collaborators_title": "No collaborators", + "group_collaborators_no_collaborators_description": "This group has no collaborators yet", + "group_collaborators_name": "Name", + "group_collaborators_email": "Email", + "group_collaborators_permission": "Permission", + "group_collaborators_added": "Added", + "group_collaborators_permission_edit": "Edit", + "group_collaborators_permission_manage": "Manage", + "group_collaborators_permission_owner": "Owner", + "group_collaborators_add_title": "Add Collaborator", + "group_collaborators_add_description": "Add a new collaborator to this group", + "group_collaborators_add_dialog_title": "Add Collaborator", + "group_collaborators_add_dialog_description": "Select a user and assign a permission level.", + "group_collaborators_add_user_label": "Select User", + "group_collaborators_add_user_placeholder": "Search by username or email...", + "group_collaborators_add_permission_label": "Permission Level", + "group_collaborators_add_permission_placeholder": "Select permission", + "group_collaborators_add_cancel": "Cancel", + "group_collaborators_add_submit": "Add Collaborator", + "group_collaborators_add_success": "Collaborator added successfully!", + "group_collaborators_add_error": "Failed to add collaborator", + "group_collaborators_users_load_error": "Failed to load users", + "group_collaborators_no_users_found": "No users found", + "group_collaborators_update_title": "Change Permission", + "group_collaborators_update_confirm_title": "Confirm Permission Change", + "group_collaborators_update_confirm_description": "Are you sure you want to change the permission for {userName} from {currentPermission} to {newPermission}?", + "group_collaborators_update_confirm": "Confirm", + "group_collaborators_update_cancel": "Cancel", + "group_collaborators_update_success": "Permission updated successfully!", + "group_collaborators_update_error": "Failed to update permission", + "group_collaborators_remove_title": "Remove Collaborator", + "group_collaborators_remove_confirm_title": "Confirm Removal", + "group_collaborators_remove_confirm_description": "Are you sure you want to remove {userName} from this group? This action cannot be undone.", + "group_collaborators_remove_confirm": "Remove", + "group_collaborators_remove_cancel": "Cancel", + "group_collaborators_remove_success": "Collaborator removed successfully!", + "group_collaborators_remove_error": "Failed to remove collaborator", + "contest_groups_title": "Contest Groups", + "contest_groups_page_title": "Groups for Contest #{contestId}", + "contest_groups_loading": "Loading groups...", + "contest_groups_load_error": "Failed to load contest groups", + "contest_groups_no_groups_title": "No groups", + "contest_groups_no_groups_description": "This contest has no groups assigned yet", + "contest_groups_add_title": "Add Groups to Contest", + "contest_groups_add_description": "Add participant groups to this contest", + "contest_groups_add_dialog_title": "Add Groups", + "contest_groups_add_dialog_description": "Select groups to add to this contest.", + "contest_groups_add_search_label": "Search Groups", + "contest_groups_add_search_placeholder": "Search by group name...", + "contest_groups_add_select_all": "Select All", + "contest_groups_add_deselect_all": "Deselect All", + "contest_groups_add_selected_count": "{count} selected", + "contest_groups_add_cancel": "Cancel", + "contest_groups_add_submit": "Add Groups", + "contest_groups_add_success": "Groups added successfully!", + "contest_groups_add_error": "Failed to add groups", + "contest_groups_groups_load_error": "Failed to load groups", + "contest_groups_no_groups_available": "No groups available", + "contest_groups_remove_title": "Remove Group", + "contest_groups_remove_confirm_title": "Confirm Removal", + "contest_groups_remove_confirm_description": "Are you sure you want to remove the group \"{groupName}\" from this contest? This will remove all group members from contest participants.", + "contest_groups_remove_confirm": "Remove", + "contest_groups_remove_cancel": "Cancel", + "contest_groups_remove_success": "Group removed successfully!", + "contest_groups_remove_error": "Failed to remove group", + "admin_contests_card_view_groups": "Manage Groups", + "sidebar_groups": "Groups" } diff --git a/messages/pl.json b/messages/pl.json index 2b4a689..1aadb07 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -739,5 +739,135 @@ "contest_user_stats_task_failed": "Nieudane", "contest_user_stats_task_not_attempted": "Nie próbowano", "contest_user_stats_expand_details": "Kliknij, aby rozwinąć rozbicie na zadania", - "contest_user_stats_collapse_details": "Kliknij, aby zwinąć rozbicie na zadania" + "contest_user_stats_collapse_details": "Kliknij, aby zwinąć rozbicie na zadania", + "groups_management_title": "Zarządzanie Grupami", + "groups_all_groups": "Wszystkie Grupy", + "groups_loading": "Ładowanie grup...", + "groups_load_error_title": "Nie udało się załadować grup", + "groups_no_groups_title": "Brak grup", + "groups_no_groups_description": "Utwórz swoją pierwszą grupę, aby zacząć", + "groups_create_title": "Utwórz Grupę", + "groups_create_description": "Utwórz nową grupę użytkowników", + "groups_create_dialog_title": "Utwórz Nową Grupę", + "groups_create_dialog_description": "Wprowadź nazwę nowej grupy.", + "groups_create_success": "Grupa została utworzona pomyślnie!", + "groups_create_error": "Nie udało się utworzyć grupy", + "groups_edit_button": "Edytuj Grupę", + "groups_edit_dialog_title": "Edytuj Grupę", + "groups_edit_dialog_description": "Zaktualizuj nazwę grupy.", + "groups_edit_success": "Grupa została zaktualizowana pomyślnie!", + "groups_edit_error": "Nie udało się zaktualizować grupy", + "groups_form_name_label": "Nazwa Grupy", + "groups_form_name_placeholder": "np. Informatyka 101 Wiosna 2024", + "groups_form_name_required": "Nazwa grupy jest wymagana", + "groups_form_name_min_length": "Nazwa grupy musi mieć co najmniej 3 znaki", + "groups_form_name_max_length": "Nazwa grupy może mieć maksymalnie 50 znaków", + "groups_form_cancel": "Anuluj", + "groups_form_create": "Utwórz Grupę", + "groups_form_update": "Zaktualizuj Grupę", + "group_card_id": "ID", + "group_card_created": "Utworzono", + "group_card_members": "Członkowie", + "group_card_view_details": "Zobacz Szczegóły", + "group_details_subtitle": "Szczegóły Grupy #{groupId}", + "group_members_title": "Członkowie Grupy", + "group_members_loading": "Ładowanie członków...", + "group_members_load_error": "Nie udało się załadować członków grupy", + "group_members_no_members_title": "Brak członków", + "group_members_no_members_description": "Ta grupa nie ma jeszcze członków", + "group_members_add_title": "Dodaj Użytkowników do Grupy", + "group_members_add_description": "Dodaj nowych członków do tej grupy", + "group_members_add_dialog_title": "Dodaj Użytkowników", + "group_members_add_dialog_description": "Wybierz użytkowników do dodania do tej grupy.", + "group_members_add_search_label": "Szukaj Użytkowników", + "group_members_add_search_placeholder": "Szukaj według nazwy użytkownika lub emaila...", + "group_members_add_select_all": "Zaznacz Wszystkich", + "group_members_add_deselect_all": "Odznacz Wszystkich", + "group_members_add_selected_count": "Zaznaczono: {count}", + "group_members_add_cancel": "Anuluj", + "group_members_add_submit": "Dodaj Użytkowników", + "group_members_add_success": "Użytkownicy zostali dodani pomyślnie!", + "group_members_add_error": "Nie udało się dodać użytkowników", + "group_members_users_load_error": "Nie udało się załadować użytkowników", + "group_members_no_users_found": "Nie znaleziono użytkowników", + "group_members_remove_title": "Usuń Użytkownika", + "group_members_remove_confirm_title": "Potwierdź Usunięcie", + "group_members_remove_confirm_description": "Czy na pewno chcesz usunąć użytkownika {userName} z tej grupy? Ta akcja jest nieodwracalna.", + "group_members_remove_confirm": "Usuń", + "group_members_remove_cancel": "Anuluj", + "group_members_remove_success": "Użytkownik został usunięty pomyślnie!", + "group_members_remove_error": "Nie udało się usunąć użytkownika", + "group_manage_collaborators_title": "Zarządzaj Współpracownikami", + "group_manage_collaborators_description": "Kontroluj, kto może zarządzać tą grupą", + "group_collaborators_title": "Współpracownicy Grupy", + "group_collaborators_page_title": "Współpracownicy dla Grupy #{groupId}", + "group_collaborators_loading": "Ładowanie współpracowników...", + "group_collaborators_load_error": "Nie udało się załadować współpracowników", + "group_collaborators_no_collaborators_title": "Brak współpracowników", + "group_collaborators_no_collaborators_description": "Ta grupa nie ma jeszcze współpracowników", + "group_collaborators_name": "Nazwa", + "group_collaborators_email": "Email", + "group_collaborators_permission": "Uprawnienia", + "group_collaborators_added": "Dodano", + "group_collaborators_permission_edit": "Edycja", + "group_collaborators_permission_manage": "Zarządzanie", + "group_collaborators_permission_owner": "Właściciel", + "group_collaborators_add_title": "Dodaj Współpracownika", + "group_collaborators_add_description": "Dodaj nowego współpracownika do tej grupy", + "group_collaborators_add_dialog_title": "Dodaj Współpracownika", + "group_collaborators_add_dialog_description": "Wybierz użytkownika i przypisz poziom uprawnień.", + "group_collaborators_add_user_label": "Wybierz Użytkownika", + "group_collaborators_add_user_placeholder": "Szukaj po nazwie użytkownika lub emailu...", + "group_collaborators_add_permission_label": "Poziom Uprawnień", + "group_collaborators_add_permission_placeholder": "Wybierz uprawnienia", + "group_collaborators_add_cancel": "Anuluj", + "group_collaborators_add_submit": "Dodaj Współpracownika", + "group_collaborators_add_success": "Współpracownik został dodany pomyślnie!", + "group_collaborators_add_error": "Nie udało się dodać współpracownika", + "group_collaborators_users_load_error": "Nie udało się załadować użytkowników", + "group_collaborators_no_users_found": "Nie znaleziono użytkowników", + "group_collaborators_update_title": "Zmień Uprawnienia", + "group_collaborators_update_confirm_title": "Potwierdź Zmianę Uprawnień", + "group_collaborators_update_confirm_description": "Czy na pewno chcesz zmienić uprawnienia użytkownika {userName} z {currentPermission} na {newPermission}?", + "group_collaborators_update_confirm": "Potwierdź", + "group_collaborators_update_cancel": "Anuluj", + "group_collaborators_update_success": "Uprawnienia zostały zaktualizowane pomyślnie!", + "group_collaborators_update_error": "Nie udało się zaktualizować uprawnień", + "group_collaborators_remove_title": "Usuń Współpracownika", + "group_collaborators_remove_confirm_title": "Potwierdź Usunięcie", + "group_collaborators_remove_confirm_description": "Czy na pewno chcesz usunąć użytkownika {userName} z tej grupy? Ta akcja jest nieodwracalna.", + "group_collaborators_remove_confirm": "Usuń", + "group_collaborators_remove_cancel": "Anuluj", + "group_collaborators_remove_success": "Współpracownik został usunięty pomyślnie!", + "group_collaborators_remove_error": "Nie udało się usunąć współpracownika", + "contest_groups_title": "Grupy Konkursu", + "contest_groups_page_title": "Grupy dla Konkursu #{contestId}", + "contest_groups_loading": "Ładowanie grup...", + "contest_groups_load_error": "Nie udało się załadować grup konkursu", + "contest_groups_no_groups_title": "Brak grup", + "contest_groups_no_groups_description": "Ten konkurs nie ma jeszcze przypisanych grup", + "contest_groups_add_title": "Dodaj Grupy do Konkursu", + "contest_groups_add_description": "Dodaj grupy uczestników do tego konkursu", + "contest_groups_add_dialog_title": "Dodaj Grupy", + "contest_groups_add_dialog_description": "Wybierz grupy do dodania do tego konkursu.", + "contest_groups_add_search_label": "Szukaj Grup", + "contest_groups_add_search_placeholder": "Szukaj według nazwy grupy...", + "contest_groups_add_select_all": "Zaznacz Wszystkie", + "contest_groups_add_deselect_all": "Odznacz Wszystkie", + "contest_groups_add_selected_count": "Zaznaczono: {count}", + "contest_groups_add_cancel": "Anuluj", + "contest_groups_add_submit": "Dodaj Grupy", + "contest_groups_add_success": "Grupy zostały dodane pomyślnie!", + "contest_groups_add_error": "Nie udało się dodać grup", + "contest_groups_groups_load_error": "Nie udało się załadować grup", + "contest_groups_no_groups_available": "Brak dostępnych grup", + "contest_groups_remove_title": "Usuń Grupę", + "contest_groups_remove_confirm_title": "Potwierdź Usunięcie", + "contest_groups_remove_confirm_description": "Czy na pewno chcesz usunąć grupę \"{groupName}\" z tego konkursu? To usunie wszystkich członków grupy z uczestników konkursu.", + "contest_groups_remove_confirm": "Usuń", + "contest_groups_remove_cancel": "Anuluj", + "contest_groups_remove_success": "Grupa została usunięta pomyślnie!", + "contest_groups_remove_error": "Nie udało się usunąć grupy", + "admin_contests_card_view_groups": "Zarządzaj Grupami", + "sidebar_groups": "Grupy" } diff --git a/src/lib/components/dashboard/DashboardSidebar.svelte b/src/lib/components/dashboard/DashboardSidebar.svelte index 92f9a14..3b42d60 100644 --- a/src/lib/components/dashboard/DashboardSidebar.svelte +++ b/src/lib/components/dashboard/DashboardSidebar.svelte @@ -73,6 +73,11 @@ href: localizeHref(AppRoutes.TeacherContests), icon: Trophy }, + { + title: () => m.sidebar_groups(), + href: localizeHref(AppRoutes.TeacherGroups), + icon: Users + }, { title: () => m.sidebar_admin_tasks(), href: localizeHref(AppRoutes.TeacherTasks), diff --git a/src/lib/components/dashboard/admin/contests/AddGroupToContestButton.svelte b/src/lib/components/dashboard/admin/contests/AddGroupToContestButton.svelte new file mode 100644 index 0000000..d864671 --- /dev/null +++ b/src/lib/components/dashboard/admin/contests/AddGroupToContestButton.svelte @@ -0,0 +1,152 @@ + + + !open && resetForm()}> + + + + + {m.contest_groups_add_dialog_title()} + + {m.contest_groups_add_dialog_description()} + + + +
+ +
+ + +
+ + +
+ + {m.contest_groups_add_selected_count({ count: selectedGroupIds.size.toString() })} + +
+ + + {#if filteredGroups.length === 0} +

{m.contest_groups_no_groups_available()}

+ {:else} +
+ {#each filteredGroups as group (group.id)} +
+ toggleGroup(group.id)} + /> + +
+ {/each} +
+ {/if} +
+ + + + + +
+
diff --git a/src/lib/components/dashboard/admin/contests/RemoveGroupFromContestButton.svelte b/src/lib/components/dashboard/admin/contests/RemoveGroupFromContestButton.svelte new file mode 100644 index 0000000..4c4e8cf --- /dev/null +++ b/src/lib/components/dashboard/admin/contests/RemoveGroupFromContestButton.svelte @@ -0,0 +1,52 @@ + + + + + + + + {m.contest_groups_remove_confirm_title()} + + {m.contest_groups_remove_confirm_description({ groupName })} + + + + + + + + + diff --git a/src/lib/components/dashboard/admin/contests/index.ts b/src/lib/components/dashboard/admin/contests/index.ts index feb4d74..15d90e0 100644 --- a/src/lib/components/dashboard/admin/contests/index.ts +++ b/src/lib/components/dashboard/admin/contests/index.ts @@ -5,3 +5,5 @@ export { default as AddContestCollaboratorButton } from './AddContestCollaborato export { default as ContestCollaboratorPermissionEditor } from './ContestCollaboratorPermissionEditor.svelte'; export { default as RemoveContestCollaboratorButton } from './RemoveContestCollaboratorButton.svelte'; export { default as RemoveTaskFromContestButton } from './RemoveTaskFromContestButton.svelte'; +export { default as AddGroupToContestButton } from './AddGroupToContestButton.svelte'; +export { default as RemoveGroupFromContestButton } from './RemoveGroupFromContestButton.svelte'; diff --git a/src/lib/components/dashboard/admin/groups/AddGroupCollaboratorButton.svelte b/src/lib/components/dashboard/admin/groups/AddGroupCollaboratorButton.svelte new file mode 100644 index 0000000..ebba2e2 --- /dev/null +++ b/src/lib/components/dashboard/admin/groups/AddGroupCollaboratorButton.svelte @@ -0,0 +1,243 @@ + + + !open && resetForm()}> + + + + + {m.group_collaborators_add_dialog_title()} + + {m.group_collaborators_add_dialog_description()} + + + + {#if usersError} +
+ {m.group_collaborators_users_load_error()} +
+ {:else if usersLoading} + + {:else} +
{ + try { + await submit(); + toast.success(m.group_collaborators_add_success()); + dialogOpen = false; + resetForm(); + } catch (error: unknown) { + if (isHttpError(error)) { + toast.error(error.body.message); + } else { + toast.error(m.group_collaborators_add_error()); + } + } + })} + class="space-y-6" + > + + + + + + +
+ + +
+ + +
+ + +
+ {#if filteredUsers.length === 0} +
+ {m.group_collaborators_no_users_found()} +
+ {:else} + {#each filteredUsers as user (user.id)} + + {/each} + {/if} +
+ + {#if selectedUser} +
+

+ Selected: {selectedUser.name} + {selectedUser.surname} (@{selectedUser.username}) +

+
+ {/if} +
+ + +
+ + (selectedPermission = value as Permission)} + > + + {selectedPermission + ? getPermissionLabel(selectedPermission) + : m.group_collaborators_add_permission_placeholder()} + + + + {m.group_collaborators_permission_edit()} + + + {m.group_collaborators_permission_manage()} + + + +
+ + + + + +
+ {/if} +
+
diff --git a/src/lib/components/dashboard/admin/groups/AddUsersToGroupButton.svelte b/src/lib/components/dashboard/admin/groups/AddUsersToGroupButton.svelte new file mode 100644 index 0000000..2786998 --- /dev/null +++ b/src/lib/components/dashboard/admin/groups/AddUsersToGroupButton.svelte @@ -0,0 +1,176 @@ + + + !open && resetForm()}> + + + + + {m.group_members_add_dialog_title()} + + {m.group_members_add_dialog_description()} + + + +
+ +
+ + +
+ + +
+ + {m.group_members_add_selected_count({ count: selectedUserIds.size.toString() })} + +
+ + + {#if usersQuery.error} +

{m.group_members_users_load_error()}

+ {:else if usersQuery.loading} +

{m.groups_loading()}

+ {:else if filteredUsers.length === 0} +

{m.group_members_no_users_found()}

+ {:else} +
+ {#each filteredUsers as user (user.id)} +
+ toggleUser(user.id)} + /> + +
+ {/each} +
+ {/if} +
+ + + + + +
+
diff --git a/src/lib/components/dashboard/admin/groups/CreateGroupButton.svelte b/src/lib/components/dashboard/admin/groups/CreateGroupButton.svelte new file mode 100644 index 0000000..938c6c3 --- /dev/null +++ b/src/lib/components/dashboard/admin/groups/CreateGroupButton.svelte @@ -0,0 +1,97 @@ + + + + + + + + {m.groups_create_dialog_title()} + + {m.groups_create_dialog_description()} + + + +
{ + try { + await submit(); + toast.success(m.groups_create_success()); + dialogOpen = false; + } catch (error) { + if (isHttpError(error)) { + toast.error(error.body.message); + } else { + toast.error(m.groups_create_error()); + } + } + })} + class="space-y-6" + > +
+ + + {#each createGroup.fields.name.issues() as issue (issue.message)} +

{issue.message}

+ {/each} +
+ + + + + +
+
+
diff --git a/src/lib/components/dashboard/admin/groups/EditGroupDialog.svelte b/src/lib/components/dashboard/admin/groups/EditGroupDialog.svelte new file mode 100644 index 0000000..f815aea --- /dev/null +++ b/src/lib/components/dashboard/admin/groups/EditGroupDialog.svelte @@ -0,0 +1,75 @@ + + + + + + {m.groups_edit_dialog_title()} + + {m.groups_edit_dialog_description()} + + + +
{ + try { + await submit(); + toast.success(m.groups_edit_success()); + dialogOpen = false; + } catch (error) { + if (isHttpError(error)) { + toast.error(error.body.message); + } else { + toast.error(m.groups_edit_error()); + } + } + })} + class="space-y-6" + > + + +
+ + + {#each updateGroup.fields.name.issues() as issue (issue.message)} +

{issue.message}

+ {/each} +
+ + + + + +
+
+
diff --git a/src/lib/components/dashboard/admin/groups/GroupCard.svelte b/src/lib/components/dashboard/admin/groups/GroupCard.svelte new file mode 100644 index 0000000..df425e8 --- /dev/null +++ b/src/lib/components/dashboard/admin/groups/GroupCard.svelte @@ -0,0 +1,75 @@ + + + + +
+ + +
+ + #{group.id} + +
+ + + {group.name} + +
+ + + +
+
+ + {m.group_card_created()} + {formatDate(group.createdAt)} +
+ +
+ + {m.admin_contests_card_created_by()} + {m.admin_contests_card_user_prefix()}{group.createdBy} +
+
+ + +
+ +
+
+
diff --git a/src/lib/components/dashboard/admin/groups/GroupCollaboratorPermissionEditor.svelte b/src/lib/components/dashboard/admin/groups/GroupCollaboratorPermissionEditor.svelte new file mode 100644 index 0000000..e5a251a --- /dev/null +++ b/src/lib/components/dashboard/admin/groups/GroupCollaboratorPermissionEditor.svelte @@ -0,0 +1,187 @@ + + +{#if isEditable} + + + + + {getPermissionLabel(currentPermission)} + {#if popoverOpen} + + {:else} + + {/if} + + +
+ + +
+
+
+{:else} + + + + {getPermissionLabel(currentPermission)} + +{/if} + + + + + {m.group_collaborators_update_confirm_title()} + + {m.group_collaborators_update_confirm_description({ + userName, + currentPermission: getPermissionLabel(currentPermission), + newPermission: selectedPermission ? getPermissionLabel(selectedPermission) : '' + })} + + + +
{ + isUpdating = true; + try { + await submit(); + toast.success(m.group_collaborators_update_success()); + dialogOpen = false; + selectedPermission = null; + } catch (error: unknown) { + if (isHttpError(error)) { + toast.error(error.body.message); + } else { + toast.error(m.group_collaborators_update_error()); + } + } finally { + isUpdating = false; + } + })} + > + + + + + + + + +
+
+
diff --git a/src/lib/components/dashboard/admin/groups/RemoveGroupCollaboratorButton.svelte b/src/lib/components/dashboard/admin/groups/RemoveGroupCollaboratorButton.svelte new file mode 100644 index 0000000..0c6ea9a --- /dev/null +++ b/src/lib/components/dashboard/admin/groups/RemoveGroupCollaboratorButton.svelte @@ -0,0 +1,112 @@ + + +{#if canRemove} + + + + + + {m.group_collaborators_remove_confirm_title()} + + {m.group_collaborators_remove_confirm_description({ userName })} + + + +
{ + isRemoving = true; + try { + await submit(); + toast.success(m.group_collaborators_remove_success()); + dialogOpen = false; + } catch (error: unknown) { + if (isHttpError(error)) { + toast.error(error.body.message); + } else { + toast.error(m.group_collaborators_remove_error()); + } + } finally { + isRemoving = false; + } + })} + > + + + + + + + +
+
+
+{/if} diff --git a/src/lib/components/dashboard/admin/groups/RemoveUserFromGroupButton.svelte b/src/lib/components/dashboard/admin/groups/RemoveUserFromGroupButton.svelte new file mode 100644 index 0000000..da31d13 --- /dev/null +++ b/src/lib/components/dashboard/admin/groups/RemoveUserFromGroupButton.svelte @@ -0,0 +1,52 @@ + + + + + + + + {m.group_members_remove_confirm_title()} + + {m.group_members_remove_confirm_description({ userName })} + + + + + + + + + diff --git a/src/lib/components/dashboard/admin/groups/index.ts b/src/lib/components/dashboard/admin/groups/index.ts new file mode 100644 index 0000000..cbf2aa2 --- /dev/null +++ b/src/lib/components/dashboard/admin/groups/index.ts @@ -0,0 +1,8 @@ +export { default as GroupCard } from './GroupCard.svelte'; +export { default as CreateGroupButton } from './CreateGroupButton.svelte'; +export { default as EditGroupDialog } from './EditGroupDialog.svelte'; +export { default as AddUsersToGroupButton } from './AddUsersToGroupButton.svelte'; +export { default as RemoveUserFromGroupButton } from './RemoveUserFromGroupButton.svelte'; +export { default as AddGroupCollaboratorButton } from './AddGroupCollaboratorButton.svelte'; +export { default as GroupCollaboratorPermissionEditor } from './GroupCollaboratorPermissionEditor.svelte'; +export { default as RemoveGroupCollaboratorButton } from './RemoveGroupCollaboratorButton.svelte'; diff --git a/src/lib/components/dashboard/contests/AdminContestCard.svelte b/src/lib/components/dashboard/contests/AdminContestCard.svelte index 11cc0b5..88476a9 100644 --- a/src/lib/components/dashboard/contests/AdminContestCard.svelte +++ b/src/lib/components/dashboard/contests/AdminContestCard.svelte @@ -179,6 +179,14 @@ {m.admin_contests_card_view_user_stats()} + diff --git a/src/lib/components/dashboard/utils.ts b/src/lib/components/dashboard/utils.ts index 5aaf73b..04d35c3 100644 --- a/src/lib/components/dashboard/utils.ts +++ b/src/lib/components/dashboard/utils.ts @@ -16,6 +16,7 @@ export function getDashboardTitleTranslationFromPathname(pathname: string): stri [AppRoutes.AvailableTasks]: () => m.sidebar_available_tasks(), [AppRoutes.Admin]: () => m.sidebar_admin(), [AppRoutes.TeacherContests]: () => m.sidebar_admin_contests(), + [AppRoutes.TeacherGroups]: () => m.groups_management_title(), [AppRoutes.TeacherTasks]: () => m.sidebar_admin_tasks(), [AppRoutes.AdminUsers]: () => m.admin_users_title() }; @@ -40,6 +41,16 @@ export function getDashboardTitleTranslationFromPathname(pathname: string): stri return m.admin_registration_requests_title(); } + // Check for teacher groups detail pages (e.g., /dashboard/teacher/groups/[groupId]) + if (path.match(/^\/dashboard\/teacher\/groups\/\d+/)) { + return m.groups_management_title(); + } + + // Check for contest groups pages (e.g., /dashboard/teacher/contests/[contestId]/groups) + if (path.match(/^\/dashboard\/teacher\/contests\/\d+\/groups/)) { + return m.contest_groups_title(); + } + // Return the translation for the route, or default to main dashboard title return routeTitleMap[path]?.() ?? m.header_dashboard(); } diff --git a/src/lib/dto/accessControl.ts b/src/lib/dto/accessControl.ts index c021d10..f0bddbc 100644 --- a/src/lib/dto/accessControl.ts +++ b/src/lib/dto/accessControl.ts @@ -12,7 +12,8 @@ export enum Permission { */ export enum ResourceType { Tasks = 'tasks', - Contests = 'contests' + Contests = 'contests', + Groups = 'groups' } /** diff --git a/src/lib/dto/group.ts b/src/lib/dto/group.ts new file mode 100644 index 0000000..8b59a83 --- /dev/null +++ b/src/lib/dto/group.ts @@ -0,0 +1,19 @@ +export interface Group { + id: number; + name: string; + createdBy: number; + createdAt: string; + updatedAt: string; +} + +export interface CreateGroupDto { + name: string; +} + +export interface EditGroupDto { + name?: string; +} + +export interface GroupUserIdsDto { + userIDs: number[]; +} diff --git a/src/lib/routes.ts b/src/lib/routes.ts index 58e4ddb..baa2bd2 100644 --- a/src/lib/routes.ts +++ b/src/lib/routes.ts @@ -23,6 +23,7 @@ export enum AppRoutes { Teacher = `${AppRoutes.Dashboard}/teacher`, TeacherContests = `${AppRoutes.Teacher}/contests`, TeacherContestsRegistrationRequests = `${AppRoutes.Teacher}/contests/`, + TeacherGroups = `${AppRoutes.Teacher}/groups`, TeacherTasks = `${AppRoutes.Teacher}/tasks`, TeacherTaskCollaborators = `${AppRoutes.TeacherTasks}/`, diff --git a/src/lib/services/ContestsManagementService.ts b/src/lib/services/ContestsManagementService.ts index 3737e1d..ace65df 100644 --- a/src/lib/services/ContestsManagementService.ts +++ b/src/lib/services/ContestsManagementService.ts @@ -11,6 +11,7 @@ import type { TaskUserStats } from '$lib/dto/contest'; import type { Task, ContestTask } from '$lib/dto/task'; +import type { Group } from '$lib/dto/group'; import type { Cookies } from '@sveltejs/kit'; import type { ApiResponse, PaginatedData } from '$lib/dto/response'; import type { Submission, GetContestSubmissionsParams } from '$lib/dto/submission'; @@ -278,6 +279,66 @@ export class ContestsManagementService { throw error; } } + + async getContestGroups(contestId: number): Promise { + try { + const response = await this.apiClient.get>({ + url: `/contests-management/contests/${contestId}/groups` + }); + return response.data; + } catch (error) { + if (error instanceof ApiError) { + console.error('Failed to get contest groups:', error.toJSON()); + throw error; + } + throw error; + } + } + + async getAssignableGroups(contestId: number): Promise { + try { + const response = await this.apiClient.get>({ + url: `/contests-management/contests/${contestId}/groups/assignable` + }); + return response.data; + } catch (error) { + if (error instanceof ApiError) { + console.error('Failed to get assignable groups:', error.toJSON()); + throw error; + } + throw error; + } + } + + async addGroupsToContest(contestId: number, groupIds: number[]): Promise { + try { + await this.apiClient.post>({ + url: `/contests-management/contests/${contestId}/groups`, + body: JSON.stringify({ groupIds }) + }); + } catch (error) { + if (error instanceof ApiError) { + console.error('Failed to add groups to contest:', error.toJSON()); + throw error; + } + throw error; + } + } + + async removeGroupsFromContest(contestId: number, groupIds: number[]): Promise { + try { + await this.apiClient.delete>({ + url: `/contests-management/contests/${contestId}/groups`, + body: JSON.stringify({ groupIds }) + }); + } catch (error) { + if (error instanceof ApiError) { + console.error('Failed to remove groups from contest:', error.toJSON()); + throw error; + } + throw error; + } + } } export function createContestsManagementService(cookies: Cookies): ContestsManagementService { diff --git a/src/lib/services/GroupsManagementService.ts b/src/lib/services/GroupsManagementService.ts new file mode 100644 index 0000000..723b10a --- /dev/null +++ b/src/lib/services/GroupsManagementService.ts @@ -0,0 +1,124 @@ +import { ApiError, createApiClient } from './ApiService'; +import type { Group, CreateGroupDto, EditGroupDto } from '$lib/dto/group'; +import type { User } from '$lib/dto/user'; +import type { Cookies } from '@sveltejs/kit'; +import type { ApiResponse } from '$lib/dto/response'; + +export class GroupsManagementService { + private apiClient; + + constructor(cookies: Cookies) { + this.apiClient = createApiClient(cookies); + } + + async getAllGroups(): Promise { + try { + const response = await this.apiClient.get>({ + url: '/groups-management/groups' + }); + return response.data; + } catch (error) { + if (error instanceof ApiError) { + console.error('Failed to get groups:', error.toJSON()); + throw error; + } + throw error; + } + } + + async getGroupById(groupId: number): Promise { + try { + const response = await this.apiClient.get>({ + url: `/groups-management/groups/${groupId}` + }); + return response.data; + } catch (error) { + if (error instanceof ApiError) { + console.error('Failed to get group:', error.toJSON()); + throw error; + } + throw error; + } + } + + async createGroup(data: CreateGroupDto): Promise<{ id: number }> { + try { + const response = await this.apiClient.post>({ + url: '/groups-management/groups', + body: JSON.stringify(data) + }); + return response.data; + } catch (error) { + if (error instanceof ApiError) { + console.error('Failed to create group:', error.toJSON()); + throw error; + } + throw error; + } + } + + async updateGroup(groupId: number, data: EditGroupDto): Promise { + try { + const response = await this.apiClient.put>({ + url: `/groups-management/groups/${groupId}`, + body: JSON.stringify(data) + }); + return response.data; + } catch (error) { + if (error instanceof ApiError) { + console.error('Failed to update group:', error.toJSON()); + throw error; + } + throw error; + } + } + + async getGroupMembers(groupId: number): Promise { + try { + const response = await this.apiClient.get>({ + url: `/groups-management/groups/${groupId}/users` + }); + return response.data; + } catch (error) { + if (error instanceof ApiError) { + console.error('Failed to get group members:', error.toJSON()); + throw error; + } + throw error; + } + } + + async addUsersToGroup(groupId: number, userIDs: number[]): Promise { + try { + await this.apiClient.post>({ + url: `/groups-management/groups/${groupId}/users`, + body: JSON.stringify({ userIDs }) + }); + } catch (error) { + if (error instanceof ApiError) { + console.error('Failed to add users to group:', error.toJSON()); + throw error; + } + throw error; + } + } + + async removeUsersFromGroup(groupId: number, userIDs: number[]): Promise { + try { + await this.apiClient.delete>({ + url: `/groups-management/groups/${groupId}/users`, + body: JSON.stringify({ userIDs }) + }); + } catch (error) { + if (error instanceof ApiError) { + console.error('Failed to remove users from group:', error.toJSON()); + throw error; + } + throw error; + } + } +} + +export function createGroupsManagementService(cookies: Cookies): GroupsManagementService { + return new GroupsManagementService(cookies); +} diff --git a/src/lib/services/index.ts b/src/lib/services/index.ts index 444f203..075a57b 100644 --- a/src/lib/services/index.ts +++ b/src/lib/services/index.ts @@ -6,6 +6,7 @@ export { ContestsManagementService, createContestsManagementService } from './ContestsManagementService'; +export { GroupsManagementService, createGroupsManagementService } from './GroupsManagementService'; export { SubmissionService } from './SubmissionService'; export { TaskService, createTaskService } from './TaskService'; export { TasksManagementService } from './TasksManagementService'; diff --git a/src/routes/dashboard/teacher/contests/[contestId]/groups/+page.server.ts b/src/routes/dashboard/teacher/contests/[contestId]/groups/+page.server.ts new file mode 100644 index 0000000..b228f4f --- /dev/null +++ b/src/routes/dashboard/teacher/contests/[contestId]/groups/+page.server.ts @@ -0,0 +1,7 @@ +export const load = async ({ parent }: { parent: () => Promise<{ contestId: number }> }) => { + const parentData = await parent(); + + return { + contestId: parentData.contestId + }; +}; diff --git a/src/routes/dashboard/teacher/contests/[contestId]/groups/+page.svelte b/src/routes/dashboard/teacher/contests/[contestId]/groups/+page.svelte new file mode 100644 index 0000000..ba00680 --- /dev/null +++ b/src/routes/dashboard/teacher/contests/[contestId]/groups/+page.svelte @@ -0,0 +1,84 @@ + + +
+
+

+ {m.contest_groups_page_title({ contestId: data.contestId.toString() })} +

+
+ + +
+

{m.admin_contests_quick_actions()}

+
+ {#if assignableQuery.current} + + {/if} +
+
+ + +
+

{m.contest_groups_title()}

+ + {#if groupsQuery.error} + groupsQuery.refresh()} + /> + {:else if groupsQuery.loading} + + {:else if groupsQuery.current && groupsQuery.current.length === 0} + + {:else if groupsQuery.current} +
+ {#each groupsQuery.current as group (group.id)} + + +
+ {group.name} + +
+
+ +

+ {m.group_card_id()}: #{group.id} +

+
+
+ {/each} +
+ {/if} +
+
diff --git a/src/routes/dashboard/teacher/contests/[contestId]/groups/groups.remote.ts b/src/routes/dashboard/teacher/contests/[contestId]/groups/groups.remote.ts new file mode 100644 index 0000000..ac3eab2 --- /dev/null +++ b/src/routes/dashboard/teacher/contests/[contestId]/groups/groups.remote.ts @@ -0,0 +1,99 @@ +import { query, command, getRequestEvent } from '$app/server'; +import { createContestsManagementService } from '$lib/services/ContestsManagementService'; +import { ApiError } from '$lib/services/ApiService'; +import type { Group } from '$lib/dto/group'; +import { error } from '@sveltejs/kit'; +import * as v from 'valibot'; + +export const getContestGroups = query(v.number(), async (contestId: number): Promise => { + const { cookies } = getRequestEvent(); + + try { + const contestsService = createContestsManagementService(cookies); + return await contestsService.getContestGroups(contestId); + } catch (err) { + console.error('Failed to load contest groups:', err); + + if (err instanceof ApiError) { + throw error(err.getStatus(), err.getApiMessage()); + } + + throw error(500, 'Failed to load contest groups'); + } +}); + +export const getAssignableGroups = query( + v.number(), + async (contestId: number): Promise => { + const { cookies } = getRequestEvent(); + + try { + const contestsService = createContestsManagementService(cookies); + return await contestsService.getAssignableGroups(contestId); + } catch (err) { + console.error('Failed to load assignable groups:', err); + + if (err instanceof ApiError) { + throw error(err.getStatus(), err.getApiMessage()); + } + + throw error(500, 'Failed to load assignable groups'); + } + } +); + +export const addGroupsToContest = command( + v.object({ + contestId: v.pipe(v.number(), v.integer()), + groupIds: v.array(v.pipe(v.number(), v.integer())) + }), + async (data) => { + const { cookies } = getRequestEvent(); + + try { + const contestsService = createContestsManagementService(cookies); + await contestsService.addGroupsToContest(data.contestId, data.groupIds); + + // Refresh contest groups + await getContestGroups(data.contestId).refresh(); + + return { success: true }; + } catch (err) { + console.error('Failed to add groups to contest:', err); + + if (err instanceof ApiError) { + throw error(err.getStatus(), err.getApiMessage()); + } + + throw error(500, 'Failed to add groups to contest'); + } + } +); + +export const removeGroupsFromContest = command( + v.object({ + contestId: v.pipe(v.number(), v.integer()), + groupIds: v.array(v.pipe(v.number(), v.integer())) + }), + async (data) => { + const { cookies } = getRequestEvent(); + + try { + const contestsService = createContestsManagementService(cookies); + await contestsService.removeGroupsFromContest(data.contestId, data.groupIds); + + // Refresh contest groups + await getContestGroups(data.contestId).refresh(); + + return { success: true }; + } catch (err) { + console.error('Failed to remove groups from contest:', err); + + if (err instanceof ApiError) { + throw error(err.getStatus(), err.getApiMessage()); + } + + throw error(500, 'Failed to remove groups from contest'); + } + } +); diff --git a/src/routes/dashboard/teacher/groups/+page.svelte b/src/routes/dashboard/teacher/groups/+page.svelte new file mode 100644 index 0000000..8293b4d --- /dev/null +++ b/src/routes/dashboard/teacher/groups/+page.svelte @@ -0,0 +1,51 @@ + + +
+
+

{m.groups_management_title()}

+
+ +
+

{m.admin_contests_quick_actions()}

+ +
+ +
+
+ + +
+

{m.groups_all_groups()}

+ + {#if groupsQuery.error} + groupsQuery.refresh()} + /> + {:else if groupsQuery.loading} + + {:else if groupsQuery.current && groupsQuery.current.length === 0} + + {:else if groupsQuery.current} +
+ {#each groupsQuery.current as group (group.id)} + + {/each} +
+ {/if} +
+
diff --git a/src/routes/dashboard/teacher/groups/[groupId]/+layout.server.ts b/src/routes/dashboard/teacher/groups/[groupId]/+layout.server.ts new file mode 100644 index 0000000..e51ea3b --- /dev/null +++ b/src/routes/dashboard/teacher/groups/[groupId]/+layout.server.ts @@ -0,0 +1,5 @@ +export const load = async ({ params }: { params: { groupId: string } }) => { + return { + groupId: Number(params.groupId) + }; +}; diff --git a/src/routes/dashboard/teacher/groups/[groupId]/+page.svelte b/src/routes/dashboard/teacher/groups/[groupId]/+page.svelte new file mode 100644 index 0000000..f8f45f1 --- /dev/null +++ b/src/routes/dashboard/teacher/groups/[groupId]/+page.svelte @@ -0,0 +1,131 @@ + + +
+ + {#if groupQuery.current} +
+
+

+ {groupQuery.current.name} +

+

+ {m.group_details_subtitle({ groupId: data.groupId.toString() })} +

+
+ +
+ {/if} + + + + + +
+

{m.group_members_title()}

+ + {#if membersQuery.error} + membersQuery.refresh()} + /> + {:else if membersQuery.loading} + + {:else if membersQuery.current && membersQuery.current.length === 0} + + {:else if membersQuery.current} +
+ {#each membersQuery.current as member (member.id)} + + +
+
+ {member.username} +

+ {member.name} + {member.surname} +

+
+ +
+ + + {member.email} + +
+
+ {/each} +
+ {/if} +
+
+ +{#if groupQuery.current} + +{/if} diff --git a/src/routes/dashboard/teacher/groups/[groupId]/collaborators/+page.server.ts b/src/routes/dashboard/teacher/groups/[groupId]/collaborators/+page.server.ts new file mode 100644 index 0000000..23cbf50 --- /dev/null +++ b/src/routes/dashboard/teacher/groups/[groupId]/collaborators/+page.server.ts @@ -0,0 +1,7 @@ +export const load = async ({ parent, locals }) => { + const parentData = await parent(); + return { + groupId: parentData.groupId, + currentUserId: locals.user?.userId ?? 0 + }; +}; diff --git a/src/routes/dashboard/teacher/groups/[groupId]/collaborators/+page.svelte b/src/routes/dashboard/teacher/groups/[groupId]/collaborators/+page.svelte new file mode 100644 index 0000000..9628acc --- /dev/null +++ b/src/routes/dashboard/teacher/groups/[groupId]/collaborators/+page.svelte @@ -0,0 +1,147 @@ + + +
+
+

+ {m.group_collaborators_page_title({ groupId: data.groupId.toString() })} +

+
+ + +
+

{m.admin_contests_quick_actions()}

+ +
+ +
+
+ + +
+

{m.group_collaborators_title()}

+ + {#if collaboratorsQuery.error} + collaboratorsQuery.refresh()} + /> + {:else if collaboratorsQuery.loading} + + {:else if collaboratorsQuery.current && collaboratorsQuery.current.length === 0} + + {:else if collaboratorsQuery.current} +
+ {#each collaboratorsQuery.current as collaborator (collaborator.userId)} + + +
+
+ {collaborator.userName} +

+ {collaborator.firstName} + {collaborator.lastName} +

+
+
+ + +
+
+ + + {collaborator.userEmail} + +
+ +
+ + {m.group_collaborators_added()}: + {formatDistanceToNow(new Date(collaborator.addedAt), { addSuffix: true })} +
+
+
+ {/each} +
+ {/if} +
+
diff --git a/src/routes/dashboard/teacher/groups/[groupId]/collaborators/collaborators.remote.ts b/src/routes/dashboard/teacher/groups/[groupId]/collaborators/collaborators.remote.ts new file mode 100644 index 0000000..cc9a938 --- /dev/null +++ b/src/routes/dashboard/teacher/groups/[groupId]/collaborators/collaborators.remote.ts @@ -0,0 +1,132 @@ +import { query, form, getRequestEvent } from '$app/server'; +import { createApiClient } from '$lib/services/ApiService'; +import { AccessControlService } from '$lib/services/AccessControlService'; +import { Permission, ResourceType } from '$lib/dto/accessControl'; +import { error } from '@sveltejs/kit'; +import * as v from 'valibot'; + +export const getGroupCollaborators = query(v.number(), async (groupId: number) => { + const { cookies } = getRequestEvent(); + + const apiClient = createApiClient(cookies); + const accessControlService = new AccessControlService(apiClient); + + const result = await accessControlService.getCollaborators(ResourceType.Groups, groupId); + + if (!result.success || !result.data) { + error(result.status, { message: result.error || 'Failed to load collaborators' }); + } + + return result.data; +}); + +export const getAssignableUsers = query(v.number(), async (groupId: number) => { + const { cookies } = getRequestEvent(); + + const apiClient = createApiClient(cookies); + const accessControlService = new AccessControlService(apiClient); + + const result = await accessControlService.getAssignableUsers(ResourceType.Groups, groupId, { + limit: 1000 + }); + + if (!result.success || !result.data) { + error(result.status, { message: result.error || 'Failed to load assignable users' }); + } + + return result.data; +}); + +export const addCollaborator = form( + v.object({ + groupId: v.pipe(v.string(), v.transform(Number), v.integer(), v.minValue(1)), + userId: v.pipe(v.string(), v.transform(Number), v.integer(), v.minValue(1)), + permission: v.picklist([Permission.Edit, Permission.Manage]) + }), + async (data) => { + const { cookies } = getRequestEvent(); + + const apiClient = createApiClient(cookies); + const accessControlService = new AccessControlService(apiClient); + + const result = await accessControlService.addCollaborator(ResourceType.Groups, data.groupId, { + user_id: data.userId, + permission: data.permission + }); + + if (!result.success) { + error(result.status, { message: result.error || 'Failed to add collaborator' }); + } + + // Refresh collaborators list + await getGroupCollaborators(data.groupId).refresh(); + + return { success: true }; + } +); + +export type AddCollaboratorForm = typeof addCollaborator; + +export const updateCollaborator = form( + v.object({ + groupId: v.pipe(v.string(), v.transform(Number), v.integer(), v.minValue(1)), + userId: v.pipe(v.string(), v.transform(Number), v.integer(), v.minValue(1)), + permission: v.picklist([Permission.Edit, Permission.Manage]) + }), + async (data) => { + const { cookies } = getRequestEvent(); + + const apiClient = createApiClient(cookies); + const accessControlService = new AccessControlService(apiClient); + + const result = await accessControlService.updateCollaborator( + ResourceType.Groups, + data.groupId, + data.userId, + { + permission: data.permission + } + ); + + if (!result.success) { + error(result.status, { message: result.error || 'Failed to update collaborator' }); + } + + // Refresh collaborators list + await getGroupCollaborators(data.groupId).refresh(); + + return { success: true }; + } +); + +export type UpdateCollaboratorForm = typeof updateCollaborator; + +export const removeCollaborator = form( + v.object({ + groupId: v.pipe(v.string(), v.transform(Number), v.integer(), v.minValue(1)), + userId: v.pipe(v.string(), v.transform(Number), v.integer(), v.minValue(1)) + }), + async (data) => { + const { cookies } = getRequestEvent(); + + const apiClient = createApiClient(cookies); + const accessControlService = new AccessControlService(apiClient); + + const result = await accessControlService.deleteCollaborator( + ResourceType.Groups, + data.groupId, + data.userId + ); + + if (!result.success) { + error(result.status, { message: result.error || 'Failed to remove collaborator' }); + } + + // Refresh collaborators list + await getGroupCollaborators(data.groupId).refresh(); + + return { success: true }; + } +); + +export type RemoveCollaboratorForm = typeof removeCollaborator; diff --git a/src/routes/dashboard/teacher/groups/[groupId]/group.remote.ts b/src/routes/dashboard/teacher/groups/[groupId]/group.remote.ts new file mode 100644 index 0000000..523f81e --- /dev/null +++ b/src/routes/dashboard/teacher/groups/[groupId]/group.remote.ts @@ -0,0 +1,160 @@ +import { query, form, command, getRequestEvent } from '$app/server'; +import { createGroupsManagementService } from '$lib/services/GroupsManagementService'; +import { ApiError, createApiClient } from '$lib/services/ApiService'; +import { UserService } from '$lib/services/UserService'; +import type { Group } from '$lib/dto/group'; +import type { User } from '$lib/dto/user'; +import type { PaginatedData } from '$lib/dto/response'; +import { error } from '@sveltejs/kit'; +import * as v from 'valibot'; +import * as m from '$lib/paraglide/messages'; + +export const getGroup = query(v.number(), async (groupId: number): Promise => { + const { cookies } = getRequestEvent(); + + try { + const groupsService = createGroupsManagementService(cookies); + return await groupsService.getGroupById(groupId); + } catch (err) { + console.error('Failed to load group:', err); + + if (err instanceof ApiError) { + throw error(err.getStatus(), err.getApiMessage()); + } + + throw error(500, 'Failed to load group'); + } +}); + +export const getGroupMembers = query(v.number(), async (groupId: number): Promise => { + const { cookies } = getRequestEvent(); + + try { + const groupsService = createGroupsManagementService(cookies); + return await groupsService.getGroupMembers(groupId); + } catch (err) { + console.error('Failed to load group members:', err); + + if (err instanceof ApiError) { + throw error(err.getStatus(), err.getApiMessage()); + } + + throw error(500, 'Failed to load group members'); + } +}); + +export const getAllUsers = query(async (): Promise> => { + const { cookies } = getRequestEvent(); + + try { + const apiClient = createApiClient(cookies); + const userService = new UserService(apiClient); + const result = await userService.listUsers({ limit: 1000 }); + + if (!result.success) { + throw error(result.status, result.error || 'Failed to load users'); + } + + return result.data!; + } catch (err) { + console.error('Failed to load users:', err); + + if (err instanceof ApiError) { + throw error(err.getStatus(), err.getApiMessage()); + } + + throw error(500, 'Failed to load users'); + } +}); + +export const updateGroup = form( + v.object({ + id: v.pipe(v.number(), v.integer()), + name: v.pipe( + v.string(), + v.nonEmpty(m.groups_form_name_required()), + v.minLength(3, m.groups_form_name_min_length()), + v.maxLength(50, m.groups_form_name_max_length()) + ) + }), + async (data) => { + const { cookies } = getRequestEvent(); + + try { + const groupsService = createGroupsManagementService(cookies); + const { id, ...groupData } = data; + const group = await groupsService.updateGroup(id, groupData); + + // Refresh group + await getGroup(id).refresh(); + + return { success: true, group }; + } catch (err) { + console.error('Failed to update group:', err); + + if (err instanceof ApiError) { + throw error(err.getStatus(), err.getApiMessage()); + } + + throw error(500, 'Failed to update group'); + } + } +); + +export type UpdateGroupForm = typeof updateGroup; + +export const addUsersToGroup = command( + v.object({ + groupId: v.pipe(v.number(), v.integer()), + userIDs: v.array(v.pipe(v.number(), v.integer())) + }), + async (data) => { + const { cookies } = getRequestEvent(); + + try { + const groupsService = createGroupsManagementService(cookies); + await groupsService.addUsersToGroup(data.groupId, data.userIDs); + + // Refresh members + await getGroupMembers(data.groupId).refresh(); + + return { success: true }; + } catch (err) { + console.error('Failed to add users to group:', err); + + if (err instanceof ApiError) { + throw error(err.getStatus(), err.getApiMessage()); + } + + throw error(500, 'Failed to add users to group'); + } + } +); + +export const removeUsersFromGroup = command( + v.object({ + groupId: v.pipe(v.number(), v.integer()), + userIDs: v.array(v.pipe(v.number(), v.integer())) + }), + async (data) => { + const { cookies } = getRequestEvent(); + + try { + const groupsService = createGroupsManagementService(cookies); + await groupsService.removeUsersFromGroup(data.groupId, data.userIDs); + + // Refresh members + await getGroupMembers(data.groupId).refresh(); + + return { success: true }; + } catch (err) { + console.error('Failed to remove users from group:', err); + + if (err instanceof ApiError) { + throw error(err.getStatus(), err.getApiMessage()); + } + + throw error(500, 'Failed to remove users from group'); + } + } +); diff --git a/src/routes/dashboard/teacher/groups/groups.remote.ts b/src/routes/dashboard/teacher/groups/groups.remote.ts new file mode 100644 index 0000000..97107b8 --- /dev/null +++ b/src/routes/dashboard/teacher/groups/groups.remote.ts @@ -0,0 +1,60 @@ +import { query, form, getRequestEvent } from '$app/server'; +import { createGroupsManagementService } from '$lib/services/GroupsManagementService'; +import { ApiError } from '$lib/services/ApiService'; +import type { Group } from '$lib/dto/group'; +import { error } from '@sveltejs/kit'; +import * as v from 'valibot'; +import * as m from '$lib/paraglide/messages'; + +export const getAllGroups = query(async (): Promise => { + const { cookies } = getRequestEvent(); + + try { + const groupsManagementService = createGroupsManagementService(cookies); + const groups = await groupsManagementService.getAllGroups(); + + return groups; + } catch (err) { + console.error('Failed to load all groups:', err); + + if (err instanceof ApiError) { + throw error(err.getStatus(), err.getApiMessage()); + } + + throw error(500, 'Failed to load groups'); + } +}); + +export const createGroup = form( + v.object({ + name: v.pipe( + v.string(), + v.nonEmpty(m.groups_form_name_required()), + v.minLength(3, m.groups_form_name_min_length()), + v.maxLength(50, m.groups_form_name_max_length()) + ) + }), + async (data) => { + const { cookies } = getRequestEvent(); + + try { + const groupsManagementService = createGroupsManagementService(cookies); + const group = await groupsManagementService.createGroup(data); + + // Refresh the groups list + await getAllGroups().refresh(); + + return { success: true, group }; + } catch (err) { + console.error('Failed to create group:', err); + + if (err instanceof ApiError) { + throw error(err.getStatus(), err.getApiMessage()); + } + + throw error(500, 'Failed to create group'); + } + } +); + +export type CreateGroupForm = typeof createGroup;