Created
September 18, 2026 14:17
-
-
Save sunmeat/ac66148c2fa9ffbdbd99ab3680506115 to your computer and use it in GitHub Desktop.
React + Web API SPF example (tanstack table)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&family=Archivo+Black&display=swap'); | |
| :root { | |
| --bg: #050914; | |
| --bg-elevated: #0c1220; | |
| --surface: #10182c; | |
| --surface-hover: #182238; | |
| --border: #23304a; | |
| --text: #f4f6fb; | |
| --text-muted: #8b96b3; | |
| --primary: #e60a3e; | |
| --primary-hover: #ff1f52; | |
| --accent: #004d98; | |
| --accent-light: #2f7bff; | |
| --violet: #3d0e5e; | |
| --gold: #ffc72c; | |
| --success: #00e28a; | |
| --danger: #ff4d5e; | |
| --radius: 16px; | |
| --radius-sm: 10px; | |
| --shadow: 0 20px 50px rgba(0, 0, 0, 0.45); | |
| --shadow-soft: 0 10px 24px rgba(0, 0, 0, 0.25); | |
| } | |
| * { | |
| box-sizing: border-box; | |
| margin: 0; | |
| padding: 0; | |
| } | |
| body { | |
| font-family: 'Inter', system-ui, -apple-system, sans-serif; | |
| background: radial-gradient(1200px 600px at 15% -10%, rgba(0, 77, 152, 0.35), transparent 60%), radial-gradient(1000px 500px at 110% 0%, rgba(230, 10, 62, 0.25), transparent 55%), var(--bg); | |
| color: var(--text); | |
| min-height: 100vh; | |
| line-height: 1.55; | |
| -webkit-font-smoothing: antialiased; | |
| } | |
| .app { | |
| display: flex; | |
| flex-direction: column; | |
| min-height: 100vh; | |
| } | |
| .header { | |
| background: linear-gradient(120deg, #050914 0%, #10182c 45%, #3d0e5e 100%); | |
| border-bottom: 1px solid var(--border); | |
| position: sticky; | |
| top: 0; | |
| z-index: 100; | |
| backdrop-filter: blur(14px); | |
| box-shadow: var(--shadow-soft); | |
| } | |
| .header-inner { | |
| max-width: 1320px; | |
| margin: 0 auto; | |
| padding: 0 32px; | |
| height: 84px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| } | |
| .brand { | |
| display: flex; | |
| align-items: center; | |
| gap: 16px; | |
| } | |
| .brand-icon { | |
| font-size: 2.1rem; | |
| filter: drop-shadow(0 4px 10px rgba(230, 10, 62, 0.5)); | |
| } | |
| .brand-text { | |
| display: flex; | |
| flex-direction: column; | |
| } | |
| .brand-title { | |
| font-family: 'Archivo Black', 'Inter', sans-serif; | |
| font-size: 1.4rem; | |
| letter-spacing: -0.5px; | |
| color: #fff; | |
| text-transform: uppercase; | |
| } | |
| .brand-sub { | |
| font-size: 0.68rem; | |
| text-transform: uppercase; | |
| letter-spacing: 2.5px; | |
| color: var(--gold); | |
| font-weight: 700; | |
| margin-top: 2px; | |
| } | |
| .nav { | |
| display: flex; | |
| gap: 4px; | |
| background: rgba(255, 255, 255, 0.04); | |
| padding: 6px; | |
| border-radius: 999px; | |
| border: 1px solid rgba(255, 255, 255, 0.06); | |
| } | |
| .nav-btn { | |
| background: transparent; | |
| border: none; | |
| color: rgba(255, 255, 255, 0.65); | |
| padding: 11px 26px; | |
| border-radius: 999px; | |
| font-weight: 700; | |
| font-size: 0.88rem; | |
| letter-spacing: 0.3px; | |
| cursor: pointer; | |
| transition: all 0.25s ease; | |
| } | |
| .nav-btn:hover { | |
| color: #fff; | |
| background: rgba(255, 255, 255, 0.08); | |
| } | |
| .nav-btn.active { | |
| background: linear-gradient(135deg, var(--primary), #b3002f); | |
| color: #fff; | |
| box-shadow: 0 8px 20px rgba(230, 10, 62, 0.45); | |
| } | |
| .main { | |
| flex: 1; | |
| max-width: 1320px; | |
| width: 100%; | |
| margin: 0 auto; | |
| padding: 48px 32px; | |
| } | |
| .alert { | |
| background: rgba(255, 77, 94, 0.12); | |
| border: 1px solid rgba(255, 77, 94, 0.4); | |
| color: #ff9aa5; | |
| padding: 16px 20px; | |
| border-radius: var(--radius-sm); | |
| margin-bottom: 28px; | |
| font-weight: 500; | |
| backdrop-filter: blur(6px); | |
| } | |
| .layout { | |
| display: grid; | |
| grid-template-columns: 380px 1fr; | |
| gap: 32px; | |
| } | |
| @media (max-width: 960px) { | |
| .layout { | |
| grid-template-columns: 1fr; | |
| } | |
| } | |
| .panel { | |
| background: linear-gradient(180deg, var(--surface) 0%, rgba(16, 24, 44, 0.7) 100%); | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius); | |
| overflow: hidden; | |
| box-shadow: var(--shadow); | |
| } | |
| .panel-head { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| padding: 22px 26px; | |
| background: linear-gradient(90deg, rgba(0, 77, 152, 0.5) 0%, transparent 100%); | |
| border-bottom: 1px solid var(--border); | |
| } | |
| .panel-head h2 { | |
| font-size: 1.15rem; | |
| font-weight: 800; | |
| color: #fff; | |
| letter-spacing: -0.2px; | |
| } | |
| .count { | |
| font-size: 0.78rem; | |
| color: #1a1a1a; | |
| font-weight: 800; | |
| background: var(--gold); | |
| padding: 5px 12px; | |
| border-radius: 999px; | |
| } | |
| .form { | |
| padding: 26px; | |
| } | |
| .field { | |
| margin-bottom: 18px; | |
| } | |
| .field label { | |
| display: block; | |
| font-size: 0.75rem; | |
| font-weight: 700; | |
| color: var(--text-muted); | |
| margin-bottom: 8px; | |
| text-transform: uppercase; | |
| letter-spacing: 1px; | |
| } | |
| .field input { | |
| width: 100%; | |
| padding: 13px 16px; | |
| background: var(--bg-elevated); | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius-sm); | |
| color: var(--text); | |
| font-size: 0.95rem; | |
| outline: none; | |
| transition: border-color 0.2s, box-shadow 0.2s, background 0.2s; | |
| } | |
| .field input:focus { | |
| border-color: var(--accent-light); | |
| background: rgba(47, 123, 255, 0.06); | |
| box-shadow: 0 0 0 4px rgba(47, 123, 255, 0.18); | |
| } | |
| .field input::placeholder { | |
| color: #4d5a78; | |
| } | |
| .field select { | |
| width: 100%; | |
| padding: 13px 40px 13px 16px; | |
| background-color: var(--bg-elevated); | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius-sm); | |
| color: var(--text); | |
| font-size: 0.95rem; | |
| outline: none; | |
| cursor: pointer; | |
| appearance: none; | |
| -webkit-appearance: none; | |
| background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%238b96b3' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M6 9l6 6 6-6'/></svg>"); | |
| background-repeat: no-repeat; | |
| background-position: right 14px center; | |
| background-size: 16px; | |
| transition: border-color 0.2s, box-shadow 0.2s, background-color 0.2s; | |
| } | |
| .field select:hover { | |
| border-color: var(--accent-light); | |
| } | |
| .field select:focus { | |
| border-color: var(--accent-light); | |
| background-color: rgba(47, 123, 255, 0.06); | |
| box-shadow: 0 0 0 4px rgba(47, 123, 255, 0.18); | |
| } | |
| .field select option { | |
| background: var(--bg-elevated); | |
| color: var(--text); | |
| } | |
| .form-actions { | |
| display: flex; | |
| gap: 12px; | |
| margin-top: 10px; | |
| } | |
| .btn { | |
| padding: 13px 22px; | |
| border-radius: var(--radius-sm); | |
| border: none; | |
| font-weight: 700; | |
| font-size: 0.9rem; | |
| letter-spacing: 0.2px; | |
| cursor: pointer; | |
| transition: transform 0.2s ease, box-shadow 0.2s ease, background 0.2s ease; | |
| } | |
| .btn-primary { | |
| background: linear-gradient(135deg, var(--primary) 0%, #ff2f61 100%); | |
| color: #fff; | |
| flex: 1; | |
| box-shadow: 0 10px 24px rgba(230, 10, 62, 0.4); | |
| } | |
| .btn-primary:hover { | |
| transform: translateY(-2px); | |
| box-shadow: 0 14px 30px rgba(230, 10, 62, 0.55); | |
| } | |
| .btn-primary:active { | |
| transform: translateY(0); | |
| } | |
| .btn-ghost { | |
| background: rgba(255, 255, 255, 0.03); | |
| color: var(--text-muted); | |
| border: 1px solid var(--border); | |
| } | |
| .btn-ghost:hover { | |
| background: var(--surface-hover); | |
| color: #fff; | |
| border-color: var(--accent-light); | |
| } | |
| .btn-danger { | |
| background: linear-gradient(135deg, var(--danger) 0%, #c9182c 100%); | |
| color: #fff; | |
| box-shadow: 0 10px 24px rgba(255, 77, 94, 0.35); | |
| } | |
| .btn-danger:hover { | |
| transform: translateY(-2px); | |
| box-shadow: 0 14px 30px rgba(255, 77, 94, 0.5); | |
| } | |
| .btn-danger:active { | |
| transform: translateY(0); | |
| } | |
| .modal-overlay { | |
| position: fixed; | |
| inset: 0; | |
| background: rgba(2, 5, 12, 0.72); | |
| backdrop-filter: blur(6px); | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| z-index: 1000; | |
| padding: 24px; | |
| animation: overlay-fade 0.2s ease; | |
| } | |
| @keyframes overlay-fade { | |
| from { | |
| opacity: 0; | |
| } | |
| to { | |
| opacity: 1; | |
| } | |
| } | |
| .modal { | |
| width: 100%; | |
| max-width: 420px; | |
| background: linear-gradient(180deg, var(--surface) 0%, var(--bg-elevated) 100%); | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius); | |
| box-shadow: var(--shadow); | |
| padding: 32px; | |
| text-align: center; | |
| animation: modal-pop 0.25s cubic-bezier(0.2, 0.9, 0.3, 1.3); | |
| } | |
| @keyframes modal-pop { | |
| from { | |
| opacity: 0; | |
| transform: translateY(12px) scale(0.96); | |
| } | |
| to { | |
| opacity: 1; | |
| transform: translateY(0) scale(1); | |
| } | |
| } | |
| .modal-icon { | |
| width: 56px; | |
| height: 56px; | |
| margin: 0 auto 18px; | |
| border-radius: 50%; | |
| background: rgba(255, 77, 94, 0.14); | |
| border: 1px solid rgba(255, 77, 94, 0.35); | |
| color: var(--danger); | |
| font-size: 1.6rem; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| } | |
| .modal-title { | |
| font-size: 1.25rem; | |
| font-weight: 800; | |
| color: #fff; | |
| margin-bottom: 10px; | |
| letter-spacing: -0.2px; | |
| } | |
| .modal-text { | |
| color: var(--text-muted); | |
| font-size: 0.92rem; | |
| line-height: 1.6; | |
| margin-bottom: 26px; | |
| } | |
| .modal-actions { | |
| display: flex; | |
| gap: 12px; | |
| } | |
| .modal-actions .btn { | |
| flex: 1; | |
| } | |
| .list-panel { | |
| display: flex; | |
| flex-direction: column; | |
| } | |
| .table-wrap { | |
| overflow-x: auto; | |
| flex: 1; | |
| } | |
| table { | |
| width: 100%; | |
| border-collapse: collapse; | |
| } | |
| th { | |
| text-align: left; | |
| padding: 16px 22px; | |
| font-size: 0.7rem; | |
| font-weight: 800; | |
| text-transform: uppercase; | |
| letter-spacing: 1.2px; | |
| color: var(--text-muted); | |
| background: var(--bg-elevated); | |
| border-bottom: 1px solid var(--border); | |
| vertical-align: top; | |
| } | |
| td { | |
| padding: 16px 22px; | |
| border-bottom: 1px solid var(--border); | |
| font-size: 0.92rem; | |
| } | |
| tbody tr { | |
| transition: background 0.2s ease; | |
| } | |
| tbody tr:hover { | |
| background: var(--surface-hover); | |
| } | |
| .th-content { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 8px; | |
| } | |
| .sortable { | |
| cursor: pointer; | |
| user-select: none; | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 6px; | |
| transition: color 0.15s ease; | |
| } | |
| .sortable:hover { | |
| color: #fff; | |
| } | |
| .sort-arrow { | |
| font-size: 0.65rem; | |
| color: var(--gold); | |
| } | |
| .filter-input { | |
| width: 100%; | |
| padding: 7px 10px; | |
| background: var(--bg); | |
| border: 1px solid var(--border); | |
| border-radius: 6px; | |
| color: var(--text); | |
| font-size: 0.8rem; | |
| font-weight: 500; | |
| outline: none; | |
| transition: border-color 0.2s, box-shadow 0.2s; | |
| } | |
| .filter-input:focus { | |
| border-color: var(--accent-light); | |
| box-shadow: 0 0 0 3px rgba(47, 123, 255, 0.15); | |
| } | |
| .filter-input::placeholder { | |
| color: #4d5a78; | |
| font-weight: 400; | |
| } | |
| .id { | |
| color: var(--text-muted); | |
| font-variant-numeric: tabular-nums; | |
| font-weight: 700; | |
| } | |
| .name { | |
| font-weight: 800; | |
| color: #fff; | |
| letter-spacing: -0.1px; | |
| } | |
| .team-name { | |
| color: var(--gold); | |
| font-weight: 700; | |
| } | |
| .coach { | |
| color: var(--text-muted); | |
| } | |
| .badge { | |
| display: inline-block; | |
| background: rgba(47, 123, 255, 0.16); | |
| color: var(--accent-light); | |
| padding: 4px 12px; | |
| border-radius: 999px; | |
| font-size: 0.76rem; | |
| font-weight: 700; | |
| border: 1px solid rgba(47, 123, 255, 0.3); | |
| } | |
| .actions { | |
| display: flex; | |
| gap: 8px; | |
| } | |
| .icon-btn { | |
| width: 36px; | |
| height: 36px; | |
| border-radius: var(--radius-sm); | |
| border: none; | |
| cursor: pointer; | |
| font-size: 0.95rem; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| transition: all 0.2s ease; | |
| } | |
| .icon-btn.edit { | |
| background: rgba(47, 123, 255, 0.14); | |
| color: var(--accent-light); | |
| } | |
| .icon-btn.edit:hover { | |
| background: var(--accent-light); | |
| color: #fff; | |
| transform: translateY(-1px); | |
| } | |
| .icon-btn.delete { | |
| background: rgba(255, 77, 94, 0.14); | |
| color: var(--danger); | |
| } | |
| .icon-btn.delete:hover { | |
| background: var(--danger); | |
| color: #fff; | |
| transform: translateY(-1px); | |
| } | |
| .empty { | |
| text-align: center; | |
| color: var(--text-muted); | |
| padding: 64px 18px !important; | |
| } | |
| .loader { | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 18px; | |
| padding: 80px 20px; | |
| color: var(--text-muted); | |
| } | |
| .spinner { | |
| width: 44px; | |
| height: 44px; | |
| border: 3px solid var(--border); | |
| border-top-color: var(--primary); | |
| border-radius: 50%; | |
| animation: spin 0.8s linear infinite; | |
| } | |
| @keyframes spin { | |
| to { | |
| transform: rotate(360deg); | |
| } | |
| } | |
| .pagination { | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| flex-wrap: wrap; | |
| gap: 10px; | |
| padding: 18px 22px; | |
| border-top: 1px solid var(--border); | |
| background: var(--bg-elevated); | |
| } | |
| .pagination-btn { | |
| width: 38px; | |
| height: 38px; | |
| border-radius: var(--radius-sm); | |
| border: 1px solid var(--border); | |
| background: rgba(255, 255, 255, 0.03); | |
| color: var(--text); | |
| font-size: 1.05rem; | |
| font-weight: 700; | |
| cursor: pointer; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| transition: all 0.2s ease; | |
| } | |
| .pagination-btn:hover:not(:disabled) { | |
| background: var(--surface-hover); | |
| border-color: var(--accent-light); | |
| color: #fff; | |
| transform: translateY(-1px); | |
| } | |
| .pagination-btn:disabled { | |
| opacity: 0.35; | |
| cursor: not-allowed; | |
| } | |
| .pagination-info { | |
| font-size: 0.88rem; | |
| color: var(--text-muted); | |
| padding: 0 8px; | |
| min-width: 120px; | |
| text-align: center; | |
| } | |
| .pagination-info strong { | |
| color: #fff; | |
| font-weight: 800; | |
| } | |
| .pagination-meta { | |
| font-size: 0.78rem; | |
| color: var(--text-muted); | |
| margin-left: 12px; | |
| padding: 5px 12px; | |
| background: rgba(255, 255, 255, 0.04); | |
| border-radius: 999px; | |
| border: 1px solid var(--border); | |
| } | |
| .footer { | |
| margin-top: auto; | |
| border-top: 1px solid var(--border); | |
| background: var(--bg-elevated); | |
| padding: 28px 32px; | |
| } | |
| .footer-inner { | |
| max-width: 1320px; | |
| margin: 0 auto; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| gap: 8px; | |
| text-align: center; | |
| color: var(--text-muted); | |
| font-size: 0.85rem; | |
| } | |
| .footer-brand { | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| font-weight: 700; | |
| color: #fff; | |
| margin-bottom: 4px; | |
| } | |
| .footer-copy { | |
| font-size: 0.75rem; | |
| opacity: 0.7; | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { useState, useEffect, useCallback, useMemo } from 'react'; | |
| import { | |
| useTable, | |
| tableFeatures, | |
| rowSortingFeature, | |
| createSortedRowModel, | |
| rowPaginationFeature, | |
| createPaginatedRowModel, | |
| columnFilteringFeature, | |
| createFilteredRowModel, | |
| createColumnHelper, | |
| filterFn_includesString, | |
| } from '@tanstack/react-table'; | |
| import './App.css'; | |
| const API_BASE_URL = '/api'; | |
| const columnHelper = createColumnHelper(); | |
| const playersTableFeatures = tableFeatures({ | |
| columnFilteringFeature, | |
| filteredRowModel: createFilteredRowModel(), | |
| filterFns: { | |
| includesString: filterFn_includesString, | |
| }, | |
| rowSortingFeature, | |
| sortedRowModel: createSortedRowModel(), | |
| rowPaginationFeature, | |
| paginatedRowModel: createPaginatedRowModel(), | |
| }); | |
| export default function App() { | |
| const [activeTab, setActiveTab] = useState('players'); | |
| const [players, setPlayers] = useState([]); | |
| const [teams, setTeams] = useState([]); | |
| const [teamOptions, setTeamOptions] = useState([]); | |
| const [loading, setLoading] = useState(false); | |
| const [error, setError] = useState(null); | |
| const [playerForm, setPlayerForm] = useState({ id: 0, name: '', age: '', position: '', teamId: '' }); | |
| const [teamForm, setTeamForm] = useState({ id: 0, name: '', coach: '' }); | |
| const [isEditing, setIsEditing] = useState(false); | |
| const playersColumns = useMemo( | |
| () => [ | |
| columnHelper.accessor('id', { | |
| header: 'ID', | |
| filterFn: 'includesString', | |
| }), | |
| columnHelper.accessor('name', { | |
| header: "Ім'я", | |
| filterFn: 'includesString', | |
| }), | |
| columnHelper.accessor('age', { | |
| header: 'Вік', | |
| filterFn: 'includesString', | |
| }), | |
| columnHelper.accessor('position', { | |
| header: 'Позиція', | |
| filterFn: 'includesString', | |
| }), | |
| columnHelper.accessor('team', { | |
| header: 'Команда', | |
| filterFn: 'includesString', | |
| }), | |
| ], | |
| [] | |
| ); | |
| const [sorting, setSorting] = useState([]); | |
| const [columnFilters, setColumnFilters] = useState([]); | |
| const [pagination, setPagination] = useState({ | |
| pageIndex: 0, | |
| pageSize: 5, | |
| }); | |
| const playersTable = useTable({ | |
| features: playersTableFeatures, | |
| data: players, | |
| columns: playersColumns, | |
| state: { sorting, columnFilters, pagination }, | |
| onSortingChange: setSorting, | |
| onColumnFiltersChange: setColumnFilters, | |
| onPaginationChange: setPagination, | |
| }); | |
| const displayPlayers = playersTable.getRowModel().rows.map((row) => row.original); | |
| const getSortHandler = (key) => playersTable.getColumn(key)?.getToggleSortingHandler(); | |
| const renderSortIcon = (key) => { | |
| const direction = playersTable.getColumn(key)?.getIsSorted(); | |
| if (!direction) return null; | |
| return <span className="sort-arrow">{direction === 'asc' ? '▲' : '▼'}</span>; | |
| }; | |
| const resetForm = useCallback(() => { | |
| setPlayerForm({ id: 0, name: '', age: '', position: '', teamId: '' }); | |
| setTeamForm({ id: 0, name: '', coach: '' }); | |
| setIsEditing(false); | |
| }, []); | |
| const refreshTeamOptions = async () => { | |
| try { | |
| const response = await fetch(`${API_BASE_URL}/teams`); | |
| if (!response.ok) return; | |
| const data = await response.json(); | |
| setTeamOptions(data); | |
| } catch { | |
| setTeamOptions([]); | |
| } | |
| }; | |
| const fetchData = useCallback(async () => { | |
| setLoading(true); | |
| setError(null); | |
| try { | |
| const endpoint = activeTab === 'players' ? 'players' : 'teams'; | |
| const response = await fetch(`${API_BASE_URL}/${endpoint}`); | |
| if (!response.ok) throw new Error(`Помилка завантаження: ${response.statusText}`); | |
| const data = await response.json(); | |
| if (activeTab === 'players') setPlayers(data); | |
| else setTeams(data); | |
| } catch (err) { | |
| setError(err.message); | |
| } finally { | |
| setLoading(false); | |
| } | |
| }, [activeTab]); | |
| useEffect(() => { | |
| let ignore = false; | |
| const load = async () => { | |
| resetForm(); | |
| setSorting([]); | |
| setColumnFilters([]); | |
| setPagination((prev) => ({ ...prev, pageIndex: 0 })); | |
| setLoading(true); | |
| setError(null); | |
| try { | |
| const endpoint = activeTab === 'players' ? 'players' : 'teams'; | |
| const response = await fetch(`${API_BASE_URL}/${endpoint}`); | |
| if (!response.ok) throw new Error(`Помилка завантаження: ${response.statusText}`); | |
| const data = await response.json(); | |
| if (activeTab === 'players') { | |
| const teamsResponse = await fetch(`${API_BASE_URL}/teams`); | |
| const teamsData = teamsResponse.ok ? await teamsResponse.json() : []; | |
| if (!ignore) { | |
| setPlayers(data); | |
| setTeamOptions(teamsData); | |
| } | |
| } else { | |
| if (!ignore) { | |
| setTeams(data); | |
| setTeamOptions(data); | |
| } | |
| } | |
| } catch (err) { | |
| if (!ignore) setError(err.message); | |
| } finally { | |
| if (!ignore) setLoading(false); | |
| } | |
| }; | |
| load(); | |
| return () => { ignore = true; }; | |
| }, [activeTab, resetForm]); | |
| const handleSubmit = async (e) => { | |
| e.preventDefault(); | |
| const isPlayer = activeTab === 'players'; | |
| const endpoint = isPlayer ? 'players' : 'teams'; | |
| const formData = isPlayer | |
| ? { ...playerForm, age: Number(playerForm.age), teamId: playerForm.teamId ? Number(playerForm.teamId) : null } | |
| : teamForm; | |
| const url = isEditing ? `${API_BASE_URL}/${endpoint}/${formData.id}` : `${API_BASE_URL}/${endpoint}`; | |
| const method = isEditing ? 'PUT' : 'POST'; | |
| try { | |
| const response = await fetch(url, { | |
| method, | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify(formData), | |
| }); | |
| if (!response.ok) throw new Error('Не вдалося зберегти дані'); | |
| resetForm(); | |
| fetchData(); | |
| if (!isPlayer) refreshTeamOptions(); | |
| } catch (err) { | |
| setError(err.message); | |
| } | |
| }; | |
| const handleEdit = (item) => { | |
| setIsEditing(true); | |
| if (activeTab === 'players') { | |
| setPlayerForm({ | |
| id: item.id, | |
| name: item.name || '', | |
| age: item.age ?? '', | |
| position: item.position || '', | |
| teamId: item.teamId ?? '', | |
| }); | |
| } else { | |
| setTeamForm({ | |
| id: item.id, | |
| name: item.name || '', | |
| coach: item.coach || '', | |
| }); | |
| } | |
| }; | |
| const [confirmDialog, setConfirmDialog] = useState(null); | |
| const requestDelete = (id, name) => { | |
| setConfirmDialog({ id, name }); | |
| }; | |
| const cancelDelete = () => { | |
| setConfirmDialog(null); | |
| }; | |
| const confirmDelete = async () => { | |
| if (!confirmDialog) return; | |
| const id = confirmDialog.id; | |
| setConfirmDialog(null); | |
| const endpoint = activeTab === 'players' ? 'players' : 'teams'; | |
| try { | |
| const response = await fetch(`${API_BASE_URL}/${endpoint}/${id}`, { method: 'DELETE' }); | |
| if (!response.ok) throw new Error('Помилка при видаленні'); | |
| fetchData(); | |
| if (activeTab === 'teams') refreshTeamOptions(); | |
| } catch (err) { | |
| setError(err.message); | |
| } | |
| }; | |
| return ( | |
| <div className="app"> | |
| <header className="header"> | |
| <div className="header-inner"> | |
| <div className="brand"> | |
| <span className="brand-icon">⚽</span> | |
| <div className="brand-text"> | |
| <span className="brand-title">Футбольна ліга</span> | |
| <span className="brand-sub">Менеджер гравців та команд</span> | |
| </div> | |
| </div> | |
| <nav className="nav"> | |
| <button | |
| className={`nav-btn ${activeTab === 'players' ? 'active' : ''}`} | |
| onClick={() => setActiveTab('players')} | |
| > | |
| Гравці | |
| </button> | |
| <button | |
| className={`nav-btn ${activeTab === 'teams' ? 'active' : ''}`} | |
| onClick={() => setActiveTab('teams')} | |
| > | |
| Команди | |
| </button> | |
| </nav> | |
| </div> | |
| </header> | |
| <main className="main"> | |
| {error && <div className="alert">{error}</div>} | |
| <div className="layout"> | |
| <section className="panel form-panel"> | |
| <div className="panel-head"> | |
| <h2>{isEditing ? 'Редагувати' : 'Додати'} {activeTab === 'players' ? 'гравця' : 'команду'}</h2> | |
| </div> | |
| <form onSubmit={handleSubmit} className="form"> | |
| {activeTab === 'players' ? ( | |
| <> | |
| <div className="field"> | |
| <label>Ім'я</label> | |
| <input | |
| type="text" | |
| required | |
| value={playerForm.name} | |
| onChange={(e) => setPlayerForm({ ...playerForm, name: e.target.value })} | |
| placeholder="Ім'я гравця" | |
| /> | |
| </div> | |
| <div className="field"> | |
| <label>Вік</label> | |
| <input | |
| type="number" | |
| required | |
| min="16" | |
| max="50" | |
| value={playerForm.age} | |
| onChange={(e) => setPlayerForm({ ...playerForm, age: e.target.value })} | |
| placeholder="Вік" | |
| /> | |
| </div> | |
| <div className="field"> | |
| <label>Позиція</label> | |
| <input | |
| type="text" | |
| required | |
| value={playerForm.position} | |
| onChange={(e) => setPlayerForm({ ...playerForm, position: e.target.value })} | |
| placeholder="Позиція" | |
| /> | |
| </div> | |
| <div className="field"> | |
| <label>Команда</label> | |
| <select | |
| value={playerForm.teamId} | |
| onChange={(e) => setPlayerForm({ ...playerForm, teamId: e.target.value })} | |
| > | |
| <option value="">Без команди</option> | |
| {teamOptions.map((t) => ( | |
| <option key={t.id} value={t.id}> | |
| {t.name} | |
| </option> | |
| ))} | |
| </select> | |
| </div> | |
| </> | |
| ) : ( | |
| <> | |
| <div className="field"> | |
| <label>Назва команди</label> | |
| <input | |
| type="text" | |
| required | |
| value={teamForm.name} | |
| onChange={(e) => setTeamForm({ ...teamForm, name: e.target.value })} | |
| placeholder="Назва команди" | |
| /> | |
| </div> | |
| <div className="field"> | |
| <label>Тренер</label> | |
| <input | |
| type="text" | |
| required | |
| value={teamForm.coach} | |
| onChange={(e) => setTeamForm({ ...teamForm, coach: e.target.value })} | |
| placeholder="Ім'я тренера" | |
| /> | |
| </div> | |
| </> | |
| )} | |
| <div className="form-actions"> | |
| <button type="submit" className="btn btn-primary"> | |
| {isEditing ? 'Зберегти' : 'Додати'} | |
| </button> | |
| {isEditing && ( | |
| <button type="button" className="btn btn-ghost" onClick={resetForm}> | |
| Скасувати | |
| </button> | |
| )} | |
| </div> | |
| </form> | |
| </section> | |
| <section className="panel list-panel"> | |
| <div className="panel-head"> | |
| <h2>{activeTab === 'players' ? 'Гравці' : 'Команди'}</h2> | |
| <span className="count"> | |
| {(activeTab === 'players' ? players : teams).length} записів | |
| </span> | |
| </div> | |
| {loading ? ( | |
| <div className="loader"> | |
| <div className="spinner" /> | |
| <span>Завантаження...</span> | |
| </div> | |
| ) : ( | |
| <> | |
| <div className="table-wrap"> | |
| <table> | |
| <thead> | |
| <tr> | |
| {activeTab === 'players' ? ( | |
| <> | |
| <th> | |
| <div className="th-content"> | |
| <span className="sortable" onClick={getSortHandler('id')}> | |
| ID{renderSortIcon('id')} | |
| </span> | |
| <input | |
| className="filter-input" | |
| placeholder="Фільтр..." | |
| value={playersTable.getColumn('id')?.getFilterValue() ?? ''} | |
| onChange={(e) => | |
| playersTable.getColumn('id')?.setFilterValue(e.target.value) | |
| } | |
| /> | |
| </div> | |
| </th> | |
| <th> | |
| <div className="th-content"> | |
| <span className="sortable" onClick={getSortHandler('name')}> | |
| Ім'я{renderSortIcon('name')} | |
| </span> | |
| <input | |
| className="filter-input" | |
| placeholder="Фільтр..." | |
| value={playersTable.getColumn('name')?.getFilterValue() ?? ''} | |
| onChange={(e) => | |
| playersTable.getColumn('name')?.setFilterValue(e.target.value) | |
| } | |
| /> | |
| </div> | |
| </th> | |
| <th> | |
| <div className="th-content"> | |
| <span className="sortable" onClick={getSortHandler('age')}> | |
| Вік{renderSortIcon('age')} | |
| </span> | |
| <input | |
| className="filter-input" | |
| placeholder="Фільтр..." | |
| value={playersTable.getColumn('age')?.getFilterValue() ?? ''} | |
| onChange={(e) => | |
| playersTable.getColumn('age')?.setFilterValue(e.target.value) | |
| } | |
| /> | |
| </div> | |
| </th> | |
| <th> | |
| <div className="th-content"> | |
| <span className="sortable" onClick={getSortHandler('position')}> | |
| Позиція{renderSortIcon('position')} | |
| </span> | |
| <input | |
| className="filter-input" | |
| placeholder="Фільтр..." | |
| value={playersTable.getColumn('position')?.getFilterValue() ?? ''} | |
| onChange={(e) => | |
| playersTable.getColumn('position')?.setFilterValue(e.target.value) | |
| } | |
| /> | |
| </div> | |
| </th> | |
| <th> | |
| <div className="th-content"> | |
| <span className="sortable" onClick={getSortHandler('team')}> | |
| Команда{renderSortIcon('team')} | |
| </span> | |
| <input | |
| className="filter-input" | |
| placeholder="Фільтр..." | |
| value={playersTable.getColumn('team')?.getFilterValue() ?? ''} | |
| onChange={(e) => | |
| playersTable.getColumn('team')?.setFilterValue(e.target.value) | |
| } | |
| /> | |
| </div> | |
| </th> | |
| </> | |
| ) : ( | |
| <> | |
| <th>ID</th> | |
| <th>Команда</th> | |
| <th>Тренер</th> | |
| </> | |
| )} | |
| <th>Дії</th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {(activeTab === 'players' ? displayPlayers : teams).length === 0 ? ( | |
| <tr> | |
| <td colSpan={activeTab === 'players' ? 6 : 4} className="empty"> | |
| Дані відсутні | |
| </td> | |
| </tr> | |
| ) : activeTab === 'players' ? ( | |
| displayPlayers.map((item) => ( | |
| <tr key={item.id}> | |
| <td className="id">{item.id}</td> | |
| <td className="name">{item.name}</td> | |
| <td>{item.age}</td> | |
| <td> | |
| <span className="badge">{item.position}</span> | |
| </td> | |
| <td className="team-name">{item.team || '—'}</td> | |
| <td> | |
| <div className="actions"> | |
| <button | |
| className="icon-btn edit" | |
| onClick={() => handleEdit(item)} | |
| title="Редагувати" | |
| > | |
| ✎ | |
| </button> | |
| <button | |
| className="icon-btn delete" | |
| onClick={() => requestDelete(item.id, item.name)} | |
| title="Видалити" | |
| > | |
| ✕ | |
| </button> | |
| </div> | |
| </td> | |
| </tr> | |
| )) | |
| ) : ( | |
| teams.map((item) => ( | |
| <tr key={item.id}> | |
| <td className="id">{item.id}</td> | |
| <td className="name">{item.name}</td> | |
| <td className="coach">{item.coach}</td> | |
| <td> | |
| <div className="actions"> | |
| <button | |
| className="icon-btn edit" | |
| onClick={() => handleEdit(item)} | |
| title="Редагувати" | |
| > | |
| ✎ | |
| </button> | |
| <button | |
| className="icon-btn delete" | |
| onClick={() => requestDelete(item.id, item.name)} | |
| title="Видалити" | |
| > | |
| ✕ | |
| </button> | |
| </div> | |
| </td> | |
| </tr> | |
| )) | |
| )} | |
| </tbody> | |
| </table> | |
| </div> | |
| {activeTab === 'players' && players.length > 0 && ( | |
| <div className="pagination"> | |
| <button | |
| className="pagination-btn" | |
| onClick={() => playersTable.setPageIndex(0)} | |
| disabled={!playersTable.getCanPreviousPage()} | |
| title="Перша сторінка" | |
| > | |
| « | |
| </button> | |
| <button | |
| className="pagination-btn" | |
| onClick={() => playersTable.previousPage()} | |
| disabled={!playersTable.getCanPreviousPage()} | |
| title="Попередня" | |
| > | |
| ‹ | |
| </button> | |
| <span className="pagination-info"> | |
| Сторінка <strong>{playersTable.state.pagination.pageIndex + 1}</strong> з{' '} | |
| <strong>{playersTable.getPageCount() || 1}</strong> | |
| </span> | |
| <button | |
| className="pagination-btn" | |
| onClick={() => playersTable.nextPage()} | |
| disabled={!playersTable.getCanNextPage()} | |
| title="Наступна" | |
| > | |
| › | |
| </button> | |
| <button | |
| className="pagination-btn" | |
| onClick={() => playersTable.setPageIndex(playersTable.getPageCount() - 1)} | |
| disabled={!playersTable.getCanNextPage()} | |
| title="Остання сторінка" | |
| > | |
| » | |
| </button> | |
| <span className="pagination-meta"> | |
| {displayPlayers.length} з {players.length} гравців | |
| </span> | |
| </div> | |
| )} | |
| </> | |
| )} | |
| </section> | |
| </div> | |
| </main> | |
| <footer className="footer"> | |
| <div className="footer-inner"> | |
| <div className="footer-brand"> | |
| <span className="brand-icon">⚽</span> | |
| <span>Футбольна ліга</span> | |
| </div> | |
| <p>Приклад: сортування + пагінація + фільтрація (TanStack Table)</p> | |
| <p className="footer-copy">© {new Date().toLocaleString('uk-UA')}</p> | |
| </div> | |
| </footer> | |
| {confirmDialog && ( | |
| <div className="modal-overlay" onClick={cancelDelete}> | |
| <div className="modal" onClick={(e) => e.stopPropagation()}> | |
| <div className="modal-icon">⚠</div> | |
| <h3 className="modal-title">Видалити запис?</h3> | |
| <p className="modal-text"> | |
| {activeTab === 'players' ? 'Гравця' : 'Команду'} «{confirmDialog.name}» буде видалено назавжди. Цю дію неможливо скасувати. | |
| </p> | |
| <div className="modal-actions"> | |
| <button className="btn btn-ghost" onClick={cancelDelete}> | |
| Скасувати | |
| </button> | |
| <button className="btn btn-danger" onClick={confirmDelete}> | |
| Видалити | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment