Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 25 additions & 2 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,8 @@
"admin_contests_card_start": "Start:",
"admin_contests_card_tasks": "Tasks",
"admin_contests_card_user_prefix": "User #",
"admin_contests_card_view_tasks": "View Contest Tasks",
"admin_contests_card_manage_tasks": "Manage Tasks",
"admin_contests_card_view_requests": "Registration Requests",
"admin_contests_card_add_tasks": "Add Tasks to Contest",
"admin_contests_create_description": "Set up a new programming contest",
"admin_contests_create_error": "Failed to create contest",
"admin_contests_create_success": "Contest created successfully!",
Expand Down Expand Up @@ -80,6 +79,30 @@
"admin_contest_tasks_remove_cancel": "Cancel",
"admin_contest_tasks_remove_success": "Task removed from contest successfully!",
"admin_contest_tasks_remove_error": "Failed to remove task from contest",
"admin_contest_tasks_manage_title": "Manage Contest Tasks",
"admin_contest_tasks_added": "Added Tasks",
"admin_contest_tasks_search_placeholder": "Search tasks...",
"admin_contest_tasks_no_results_title": "No tasks found",
"admin_contest_tasks_column_creator": "Creator",
"admin_contest_tasks_edit_schedule_title": "Edit Schedule",
"admin_contest_tasks_edit_dialog_title": "Edit Task Schedule",
"admin_contest_tasks_edit_dialog_description": "Update when this task is available in the contest.",
"admin_contest_tasks_update_button": "Save Changes",
"admin_contest_tasks_update_success": "Task schedule updated successfully!",
"admin_contest_tasks_update_error": "Failed to update task schedule",
"admin_contest_tasks_column_id": "ID",
"admin_contest_tasks_column_title": "Title",
"admin_contest_tasks_column_start_at": "Available From",
"admin_contest_tasks_column_end_at": "Available Until",
"admin_contest_tasks_column_created_at": "Created At",
"admin_contest_tasks_column_actions": "Actions",
"admin_contest_tasks_pagination_page": "Page",
"admin_contest_tasks_pagination_of": "of",
"admin_contest_tasks_pagination_rows_per_page": "Rows per page",
"admin_contest_tasks_pagination_total": "{total} total tasks",
"admin_contest_tasks_pagination_prev": "Previous",
"admin_contest_tasks_pagination_next": "Next",
"admin_contest_tasks_pagination_showing_range": "Showing {from}-{to} of {total} tasks",
"admin_registration_requests_approve": "Approve",
"admin_registration_requests_email": "Email",
"admin_registration_requests_load_error": "Failed to load registration requests",
Expand Down
27 changes: 25 additions & 2 deletions messages/pl.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,8 @@
"admin_contests_card_start": "Początek:",
"admin_contests_card_tasks": "Zadania",
"admin_contests_card_user_prefix": "Użytkownik #",
"admin_contests_card_view_tasks": "Zobacz Zadania Konkursu",
"admin_contests_card_manage_tasks": "Zarządzaj Zadania",
"admin_contests_card_view_requests": "Prośby o Rejestrację",
"admin_contests_card_add_tasks": "Dodaj Zadania do Konkursu",
"admin_contests_create_description": "Skonfiguruj nowy konkurs programistyczny",
"admin_contests_create_error": "Nie udało się utworzyć konkursu",
"admin_contests_create_success": "Konkurs został pomyślnie utworzony!",
Expand Down Expand Up @@ -80,6 +79,30 @@
"admin_contest_tasks_remove_cancel": "Anuluj",
"admin_contest_tasks_remove_success": "Zadanie zostało pomyślnie usunięte z konkursu!",
"admin_contest_tasks_remove_error": "Nie udało się usunąć zadania z konkursu",
"admin_contest_tasks_manage_title": "Zarządzaj Zadaniami Konkursu",
"admin_contest_tasks_added": "Dodane Zadania",
"admin_contest_tasks_search_placeholder": "Szukaj zadań...",
"admin_contest_tasks_no_results_title": "Nie znaleziono zadań",
"admin_contest_tasks_column_creator": "Autor",
"admin_contest_tasks_edit_schedule_title": "Edytuj Harmonogram",
"admin_contest_tasks_edit_dialog_title": "Edytuj Harmonogram Zadania",
"admin_contest_tasks_edit_dialog_description": "Zaktualizuj, kiedy to zadanie będzie dostępne w konkursie.",
"admin_contest_tasks_update_button": "Zapisz Zmiany",
"admin_contest_tasks_update_success": "Harmonogram zadania zaktualizowany pomyślnie!",
"admin_contest_tasks_update_error": "Nie udało się zaktualizować harmonogramu zadania",
"admin_contest_tasks_column_id": "ID",
"admin_contest_tasks_column_title": "Tytuł",
"admin_contest_tasks_column_start_at": "Dostępne od",
"admin_contest_tasks_column_end_at": "Dostępne do",
"admin_contest_tasks_column_created_at": "Utworzono",
"admin_contest_tasks_column_actions": "Akcje",
"admin_contest_tasks_pagination_page": "Strona",
"admin_contest_tasks_pagination_of": "z",
"admin_contest_tasks_pagination_rows_per_page": "Wierszy na stronę",
"admin_contest_tasks_pagination_total": "{total} zadań łącznie",
"admin_contest_tasks_pagination_prev": "Poprzednia",
"admin_contest_tasks_pagination_next": "Następna",
"admin_contest_tasks_pagination_showing_range": "Wyświetlanie {from}-{to} z {total} zadań",
"admin_registration_requests_approve": "Zatwierdź",
"admin_registration_requests_email": "Email",
"admin_registration_requests_load_error": "Nie udało się załadować próśb o rejestrację",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,288 @@
<script lang="ts">
import type { ContestTask } from '$lib/dto/task';
import { SortDirection, ContestTaskSortKey } from '$lib/dto/pagination';
import * as Table from '$lib/components/ui/table';
import * as Pagination from '$lib/components/ui/pagination';
import * as Tooltip from '$lib/components/ui/tooltip';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import ListTodo from '@lucide/svelte/icons/list-todo';
import User from '@lucide/svelte/icons/user';
import Calendar from '@lucide/svelte/icons/calendar';
import Search from '@lucide/svelte/icons/search';
import Pencil from '@lucide/svelte/icons/pencil';
import ArrowUp from '@lucide/svelte/icons/arrow-up';
import ArrowDown from '@lucide/svelte/icons/arrow-down';
import * as m from '$lib/paraglide/messages';
import { getPaginationPages, getCurrentPage, getTotalPages, getOffset, formatDate } from '$lib/utils';
import RemoveTaskFromContestButton from './RemoveTaskFromContestButton.svelte';

interface AddedContestTasksListProps {
contestTasks: ContestTask[];
total: number;
contestId: number;
limit: number;
offset: number;
sortKey: ContestTaskSortKey;
sortDir: SortDirection;
search: string;
onEdit: (contestTask: ContestTask) => void;
onSuccess?: () => void;
}

let {
contestTasks,
total,
contestId,
limit = $bindable(),
offset = $bindable(),
sortKey = $bindable(),
sortDir = $bindable(),
search = $bindable(),
onEdit,
onSuccess
}: AddedContestTasksListProps = $props();

let currentPage = $derived(getCurrentPage(offset, limit));
let totalPages = $derived(getTotalPages(total, limit));

let paginationPages = $derived(getPaginationPages(currentPage, totalPages));

function handleHeaderSort(key: ContestTaskSortKey) {
const dir =
sortKey === key
? sortDir === SortDirection.Asc
? SortDirection.Desc
: SortDirection.Asc
: SortDirection.Asc;

sortKey = key;
sortDir = dir;
offset = 0;
}

function handleLimitChange(newLimit: number) {
limit = newLimit;
offset = 0;
}

function getAriaSortValue(key: ContestTaskSortKey): 'ascending' | 'descending' | 'none' {
if (sortKey !== key) return 'none';
return sortDir === SortDirection.Asc ? 'ascending' : 'descending';
}

const perPageOptions = [5, 10, 20];
</script>

<div class="space-y-3">
<!-- Top bar: range + page size + search -->
<div class="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between">
<div class="relative w-full lg:max-w-xs">
<Search class="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
type="text"
placeholder={m.admin_contest_tasks_search_placeholder()}
bind:value={search}
class="pl-9"
/>
</div>

<div class="flex flex-wrap items-center gap-2">
<div class="text-xs text-muted-foreground">
{m.admin_contest_tasks_pagination_showing_range({
from: total === 0 ? 0 : offset + 1,
to: Math.min(offset + contestTasks.length, total),
total
})}
</div>
<div class="flex items-center gap-1 text-xs">
<span class="text-muted-foreground">{m.admin_contest_tasks_pagination_rows_per_page()}:</span>
<select
class="rounded-md border bg-background px-2 py-1 text-xs"
value={limit}
onchange={(e) => handleLimitChange(Number((e.target as HTMLSelectElement).value))}
>
{#each perPageOptions as opt (opt)}
<option value={opt}>{opt}</option>
{/each}
</select>
</div>
<div class="text-xs font-medium">
{m.admin_contest_tasks_pagination_page()}
{currentPage}
{m.admin_contest_tasks_pagination_of()}
{totalPages}
</div>
</div>
</div>

<!-- Data table -->
<Table.Root class="mt-1">
<Table.Header>
<Table.Row>
<Table.Head class="w-14" aria-sort={getAriaSortValue(ContestTaskSortKey.Id)}>
<button
class="flex items-center gap-1"
type="button"
onclick={() => handleHeaderSort(ContestTaskSortKey.Id)}
>
{m.admin_contest_tasks_column_id()}
{#if sortKey === ContestTaskSortKey.Id}{#if sortDir === SortDirection.Asc}<ArrowUp
class="h-3 w-3 text-muted-foreground"
/>{:else}<ArrowDown class="h-3 w-3 text-muted-foreground" />{/if}{/if}
</button>
</Table.Head>
<Table.Head aria-sort={getAriaSortValue(ContestTaskSortKey.Title)}>
<button
class="flex items-center gap-1"
type="button"
onclick={() => handleHeaderSort(ContestTaskSortKey.Title)}
>
{m.admin_contest_tasks_column_title()}
{#if sortKey === ContestTaskSortKey.Title}{#if sortDir === SortDirection.Asc}<ArrowUp
class="h-3 w-3 text-muted-foreground"
/>{:else}<ArrowDown class="h-3 w-3 text-muted-foreground" />{/if}{/if}
</button>
</Table.Head>
<Table.Head class="hidden md:table-cell">
{m.admin_contest_tasks_column_creator()}
</Table.Head>
<Table.Head aria-sort={getAriaSortValue(ContestTaskSortKey.StartAt)}>
<button
class="flex items-center gap-1"
type="button"
onclick={() => handleHeaderSort(ContestTaskSortKey.StartAt)}
>
{m.admin_contest_tasks_column_start_at()}
{#if sortKey === ContestTaskSortKey.StartAt}{#if sortDir === SortDirection.Asc}<ArrowUp
class="h-3 w-3 text-muted-foreground"
/>{:else}<ArrowDown class="h-3 w-3 text-muted-foreground" />{/if}{/if}
</button>
</Table.Head>
<Table.Head aria-sort={getAriaSortValue(ContestTaskSortKey.EndAt)}>
<button
class="flex items-center gap-1"
type="button"
onclick={() => handleHeaderSort(ContestTaskSortKey.EndAt)}
>
{m.admin_contest_tasks_column_end_at()}
{#if sortKey === ContestTaskSortKey.EndAt}{#if sortDir === SortDirection.Asc}<ArrowUp
class="h-3 w-3 text-muted-foreground"
/>{:else}<ArrowDown class="h-3 w-3 text-muted-foreground" />{/if}{/if}
</button>
</Table.Head>
<Table.Head class="text-right">{m.admin_contest_tasks_column_actions()}</Table.Head>
</Table.Row>
</Table.Header>

<Table.Body>
{#if contestTasks.length === 0}
<Table.Row>
<Table.Cell colspan={6} class="py-10 text-center text-sm text-muted-foreground">
{m.admin_contest_tasks_no_results_title()}
</Table.Cell>
</Table.Row>
{:else}
{#each contestTasks as contestTask (contestTask.task.id)}
<Table.Row>
<Table.Cell class="font-mono text-xs">{contestTask.task.id}</Table.Cell>
<Table.Cell>
<div class="flex min-w-0 items-center gap-2">
<ListTodo class="h-4 w-4 shrink-0 text-primary" />
<Tooltip.Root>
<Tooltip.Trigger class="min-w-0">
<span class="block max-w-48 truncate font-medium text-foreground">
{contestTask.task.title}
</span>
</Tooltip.Trigger>
<Tooltip.Content side="top">{contestTask.task.title}</Tooltip.Content>
</Tooltip.Root>
</div>
</Table.Cell>
<Table.Cell class="hidden md:table-cell">
<div class="flex items-center gap-1.5 text-muted-foreground">
<User class="h-3.5 w-3.5 shrink-0" />
<span class="max-w-[160px] truncate">{contestTask.creatorName}</span>
</div>
</Table.Cell>
<Table.Cell>
<div class="flex items-center gap-1.5 text-muted-foreground">
<Calendar class="h-3.5 w-3.5 shrink-0" />
<span class="whitespace-nowrap">{formatDate(contestTask.startAt)}</span>
</div>
</Table.Cell>
<Table.Cell class="text-muted-foreground">
{#if contestTask.endAt}
<div class="flex items-center gap-1.5">
<Calendar class="h-3.5 w-3.5 shrink-0" />
<span class="whitespace-nowrap">{formatDate(contestTask.endAt)}</span>
</div>
{:else}
<span>{m.admin_contest_tasks_no_end_date()}</span>
{/if}
</Table.Cell>
<Table.Cell class="text-right">
<div class="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="icon"
class="h-8 w-8 text-muted-foreground"
title={m.admin_contest_tasks_edit_schedule_title()}
onclick={() => onEdit(contestTask)}
>
<Pencil class="h-4 w-4" />
</Button>
<RemoveTaskFromContestButton
{contestId}
taskId={contestTask.task.id}
taskTitle={contestTask.task.title}
onSuccess={onSuccess}
/>
</div>
</Table.Cell>
</Table.Row>
{/each}
{/if}
</Table.Body>
</Table.Root>

{#if total > 0}
<div class="flex flex-col items-center gap-2 pt-2">
<Pagination.Root
count={total}
perPage={limit}
page={currentPage}
siblingCount={1}
onPageChange={(p) => {
if (p && p !== currentPage) offset = getOffset(p, limit);
}}
>
<Pagination.Content>
<Pagination.PrevButton disabled={currentPage === 1}>
{m.admin_contest_tasks_pagination_prev()}
</Pagination.PrevButton>

{#each paginationPages as p (p)}
{#if p === 'ellipsis'}
<Pagination.Item>
<Pagination.Ellipsis />
</Pagination.Item>
{:else}
<Pagination.Item>
<Pagination.Link page={{ type: 'page', value: p }} isActive={p === currentPage} />
</Pagination.Item>
{/if}
{/each}

<Pagination.NextButton disabled={currentPage === totalPages}>
{m.admin_contest_tasks_pagination_next()}
</Pagination.NextButton>
</Pagination.Content>
</Pagination.Root>

<div class="text-xs text-muted-foreground">
{m.admin_contest_tasks_pagination_total({ total })}
</div>
</div>
{/if}
</div>
Loading
Loading