@package-uploader/ui 1.1.1 → 1.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/index-BZdhKDnY.js +71 -0
- package/dist/assets/{index-C19M6liw.css → index-BfowR104.css} +1 -1
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/api/client.ts +9 -0
- package/src/components/CourseStructureStep.tsx +316 -93
- package/src/components/UploadModal.tsx +8 -2
- package/src/hooks/useBlockGrouping.ts +184 -0
- package/src/index.css +259 -0
- package/dist/assets/index-Ca5beg0c.js +0 -71
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { useState, useCallback, useMemo } from 'react';
|
|
2
|
+
|
|
3
|
+
/** A group of adjacent blocks wrapped in a container div at runtime */
|
|
4
|
+
export interface BlockGroup {
|
|
5
|
+
id: string;
|
|
6
|
+
lessonId: string;
|
|
7
|
+
blockIds: string[];
|
|
8
|
+
className: string;
|
|
9
|
+
label?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface UseBlockGroupingProps {
|
|
13
|
+
/** All block IDs in the current lesson, in order */
|
|
14
|
+
lessonBlockIds: string[];
|
|
15
|
+
/** Current lesson ID */
|
|
16
|
+
lessonId: string;
|
|
17
|
+
/** Existing groups for this lesson */
|
|
18
|
+
groups: BlockGroup[];
|
|
19
|
+
/** Callback when groups change */
|
|
20
|
+
onGroupsChange: (groups: BlockGroup[]) => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface UseBlockGroupingReturn {
|
|
24
|
+
/** Currently selected block IDs (for creating a new group) */
|
|
25
|
+
selectedBlockIds: Set<string>;
|
|
26
|
+
/** Toggle selection of a block */
|
|
27
|
+
toggleBlock: (blockId: string) => void;
|
|
28
|
+
/** Clear selection */
|
|
29
|
+
clearSelection: () => void;
|
|
30
|
+
/** Whether current selection forms a valid group (adjacent, min 2, not already grouped) */
|
|
31
|
+
canCreateGroup: boolean;
|
|
32
|
+
/** Reason why group can't be created (empty if valid) */
|
|
33
|
+
validationError: string;
|
|
34
|
+
/** Create a group from current selection */
|
|
35
|
+
createGroup: (className: string, label?: string) => void;
|
|
36
|
+
/** Remove a group (ungroup) */
|
|
37
|
+
removeGroup: (groupId: string) => void;
|
|
38
|
+
/** Update a group's className or label */
|
|
39
|
+
updateGroup: (groupId: string, updates: { className?: string; label?: string }) => void;
|
|
40
|
+
/** Check if a block is already in a group */
|
|
41
|
+
isBlockGrouped: (blockId: string) => boolean;
|
|
42
|
+
/** Get the group a block belongs to (if any) */
|
|
43
|
+
getBlockGroup: (blockId: string) => BlockGroup | undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let nextGroupId = 1;
|
|
47
|
+
function generateGroupId(): string {
|
|
48
|
+
return `bg_${Date.now()}_${nextGroupId++}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function useBlockGrouping({
|
|
52
|
+
lessonBlockIds,
|
|
53
|
+
lessonId,
|
|
54
|
+
groups,
|
|
55
|
+
onGroupsChange,
|
|
56
|
+
}: UseBlockGroupingProps): UseBlockGroupingReturn {
|
|
57
|
+
const [selectedBlockIds, setSelectedBlockIds] = useState<Set<string>>(new Set());
|
|
58
|
+
|
|
59
|
+
// Map: blockId -> group it belongs to
|
|
60
|
+
const blockToGroup = useMemo(() => {
|
|
61
|
+
const map = new Map<string, BlockGroup>();
|
|
62
|
+
for (const group of groups) {
|
|
63
|
+
for (const blockId of group.blockIds) {
|
|
64
|
+
map.set(blockId, group);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return map;
|
|
68
|
+
}, [groups]);
|
|
69
|
+
|
|
70
|
+
const isBlockGrouped = useCallback(
|
|
71
|
+
(blockId: string) => blockToGroup.has(blockId),
|
|
72
|
+
[blockToGroup]
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const getBlockGroup = useCallback(
|
|
76
|
+
(blockId: string) => blockToGroup.get(blockId),
|
|
77
|
+
[blockToGroup]
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
const toggleBlock = useCallback((blockId: string) => {
|
|
81
|
+
setSelectedBlockIds((prev) => {
|
|
82
|
+
const next = new Set(prev);
|
|
83
|
+
if (next.has(blockId)) {
|
|
84
|
+
next.delete(blockId);
|
|
85
|
+
} else {
|
|
86
|
+
next.add(blockId);
|
|
87
|
+
}
|
|
88
|
+
return next;
|
|
89
|
+
});
|
|
90
|
+
}, []);
|
|
91
|
+
|
|
92
|
+
const clearSelection = useCallback(() => {
|
|
93
|
+
setSelectedBlockIds(new Set());
|
|
94
|
+
}, []);
|
|
95
|
+
|
|
96
|
+
// Validate current selection
|
|
97
|
+
const { canCreateGroup, validationError } = useMemo(() => {
|
|
98
|
+
if (selectedBlockIds.size < 2) {
|
|
99
|
+
return { canCreateGroup: false, validationError: 'Select at least 2 blocks' };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Check none are already grouped
|
|
103
|
+
for (const blockId of selectedBlockIds) {
|
|
104
|
+
if (blockToGroup.has(blockId)) {
|
|
105
|
+
return { canCreateGroup: false, validationError: 'A selected block is already in a group' };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Check adjacency: selected blocks must be contiguous in lessonBlockIds
|
|
110
|
+
const indices = Array.from(selectedBlockIds)
|
|
111
|
+
.map((id) => lessonBlockIds.indexOf(id))
|
|
112
|
+
.filter((i) => i !== -1)
|
|
113
|
+
.sort((a, b) => a - b);
|
|
114
|
+
|
|
115
|
+
if (indices.length !== selectedBlockIds.size) {
|
|
116
|
+
return { canCreateGroup: false, validationError: 'Some selected blocks not found in lesson' };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
for (let i = 1; i < indices.length; i++) {
|
|
120
|
+
if (indices[i] !== indices[i - 1] + 1) {
|
|
121
|
+
return { canCreateGroup: false, validationError: 'Blocks must be adjacent' };
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return { canCreateGroup: true, validationError: '' };
|
|
126
|
+
}, [selectedBlockIds, lessonBlockIds, blockToGroup]);
|
|
127
|
+
|
|
128
|
+
const createGroup = useCallback(
|
|
129
|
+
(className: string, label?: string) => {
|
|
130
|
+
if (!canCreateGroup) return;
|
|
131
|
+
|
|
132
|
+
// Order selected blocks by their position in the lesson
|
|
133
|
+
const orderedBlockIds = lessonBlockIds.filter((id) => selectedBlockIds.has(id));
|
|
134
|
+
|
|
135
|
+
const newGroup: BlockGroup = {
|
|
136
|
+
id: generateGroupId(),
|
|
137
|
+
lessonId,
|
|
138
|
+
blockIds: orderedBlockIds,
|
|
139
|
+
className: className.trim(),
|
|
140
|
+
label: label?.trim() || undefined,
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
onGroupsChange([...groups, newGroup]);
|
|
144
|
+
setSelectedBlockIds(new Set());
|
|
145
|
+
},
|
|
146
|
+
[canCreateGroup, lessonBlockIds, selectedBlockIds, lessonId, groups, onGroupsChange]
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
const removeGroup = useCallback(
|
|
150
|
+
(groupId: string) => {
|
|
151
|
+
onGroupsChange(groups.filter((g) => g.id !== groupId));
|
|
152
|
+
},
|
|
153
|
+
[groups, onGroupsChange]
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
const updateGroup = useCallback(
|
|
157
|
+
(groupId: string, updates: { className?: string; label?: string }) => {
|
|
158
|
+
onGroupsChange(
|
|
159
|
+
groups.map((g) => {
|
|
160
|
+
if (g.id !== groupId) return g;
|
|
161
|
+
return {
|
|
162
|
+
...g,
|
|
163
|
+
...(updates.className !== undefined && { className: updates.className.trim() }),
|
|
164
|
+
...(updates.label !== undefined && { label: updates.label.trim() || undefined }),
|
|
165
|
+
};
|
|
166
|
+
})
|
|
167
|
+
);
|
|
168
|
+
},
|
|
169
|
+
[groups, onGroupsChange]
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
selectedBlockIds,
|
|
174
|
+
toggleBlock,
|
|
175
|
+
clearSelection,
|
|
176
|
+
canCreateGroup,
|
|
177
|
+
validationError,
|
|
178
|
+
createGroup,
|
|
179
|
+
removeGroup,
|
|
180
|
+
updateGroup,
|
|
181
|
+
isBlockGrouped,
|
|
182
|
+
getBlockGroup,
|
|
183
|
+
};
|
|
184
|
+
}
|
package/src/index.css
CHANGED
|
@@ -1685,3 +1685,262 @@ body {
|
|
|
1685
1685
|
transform: translateY(0);
|
|
1686
1686
|
}
|
|
1687
1687
|
}
|
|
1688
|
+
|
|
1689
|
+
/* ========================================
|
|
1690
|
+
BLOCK CARDS (visual block preview)
|
|
1691
|
+
======================================== */
|
|
1692
|
+
|
|
1693
|
+
.block-cards {
|
|
1694
|
+
display: flex;
|
|
1695
|
+
flex-direction: column;
|
|
1696
|
+
gap: 3px;
|
|
1697
|
+
padding: 0.375rem 0;
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
.block-card {
|
|
1701
|
+
display: flex;
|
|
1702
|
+
flex-direction: column;
|
|
1703
|
+
gap: 0.25rem;
|
|
1704
|
+
padding: 0.5rem 0.625rem;
|
|
1705
|
+
border-radius: var(--radius-sm);
|
|
1706
|
+
border: 1px solid var(--color-border);
|
|
1707
|
+
border-left: 4px solid var(--block-accent, #6b7280);
|
|
1708
|
+
background: var(--color-bg);
|
|
1709
|
+
cursor: pointer;
|
|
1710
|
+
transition: all 0.15s;
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
.block-card:hover:not(.grouped) {
|
|
1714
|
+
border-color: var(--color-primary);
|
|
1715
|
+
border-left-color: var(--block-accent, #6b7280);
|
|
1716
|
+
background: rgba(37, 99, 235, 0.03);
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1719
|
+
.block-card.selected {
|
|
1720
|
+
background: rgba(37, 99, 235, 0.08);
|
|
1721
|
+
border-color: var(--color-primary);
|
|
1722
|
+
border-left-color: var(--block-accent, #6b7280);
|
|
1723
|
+
box-shadow: 0 0 0 1px rgba(37, 99, 235, 0.2);
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
.block-card.grouped {
|
|
1727
|
+
cursor: default;
|
|
1728
|
+
opacity: 0.75;
|
|
1729
|
+
background: var(--color-bg-secondary);
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
.block-card-header {
|
|
1733
|
+
display: flex;
|
|
1734
|
+
align-items: center;
|
|
1735
|
+
gap: 0.5rem;
|
|
1736
|
+
min-height: 1.25rem;
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
.block-card-type {
|
|
1740
|
+
font-size: 0.8rem;
|
|
1741
|
+
font-weight: 600;
|
|
1742
|
+
color: var(--color-text);
|
|
1743
|
+
white-space: nowrap;
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
.block-card-title {
|
|
1747
|
+
font-size: 0.75rem;
|
|
1748
|
+
color: var(--color-text-muted);
|
|
1749
|
+
overflow: hidden;
|
|
1750
|
+
text-overflow: ellipsis;
|
|
1751
|
+
white-space: nowrap;
|
|
1752
|
+
flex: 1;
|
|
1753
|
+
min-width: 0;
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
.block-card-check {
|
|
1757
|
+
color: var(--color-primary);
|
|
1758
|
+
font-weight: 700;
|
|
1759
|
+
font-size: 0.875rem;
|
|
1760
|
+
flex-shrink: 0;
|
|
1761
|
+
margin-left: auto;
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
.block-card .tree-class-input-sm {
|
|
1765
|
+
margin-left: 0;
|
|
1766
|
+
max-width: 100%;
|
|
1767
|
+
margin-top: 0;
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
/* Group container wrapping adjacent block cards */
|
|
1771
|
+
.block-group-container {
|
|
1772
|
+
border: 2px dashed var(--color-primary);
|
|
1773
|
+
border-radius: var(--radius);
|
|
1774
|
+
padding: 0.375rem;
|
|
1775
|
+
margin: 0.25rem 0;
|
|
1776
|
+
background: rgba(37, 99, 235, 0.02);
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
.block-group-container .block-card {
|
|
1780
|
+
border-left-width: 3px;
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
.bgp-group-header {
|
|
1784
|
+
display: flex;
|
|
1785
|
+
align-items: center;
|
|
1786
|
+
gap: 0.5rem;
|
|
1787
|
+
padding: 0.25rem 0.5rem;
|
|
1788
|
+
font-size: 0.7rem;
|
|
1789
|
+
border-bottom: 1px solid rgba(37, 99, 235, 0.15);
|
|
1790
|
+
margin-bottom: 0.25rem;
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1793
|
+
.bgp-group-label {
|
|
1794
|
+
font-weight: 600;
|
|
1795
|
+
color: var(--color-primary);
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
.bgp-group-class {
|
|
1799
|
+
color: var(--color-text-muted);
|
|
1800
|
+
font-family: monospace;
|
|
1801
|
+
font-size: 0.65rem;
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1804
|
+
.bgp-btn {
|
|
1805
|
+
background: none;
|
|
1806
|
+
border: none;
|
|
1807
|
+
font-size: 0.65rem;
|
|
1808
|
+
cursor: pointer;
|
|
1809
|
+
padding: 0.125rem 0.375rem;
|
|
1810
|
+
border-radius: var(--radius-sm);
|
|
1811
|
+
transition: all 0.15s;
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
.bgp-btn-edit {
|
|
1815
|
+
color: var(--color-text-muted);
|
|
1816
|
+
margin-left: auto;
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1819
|
+
.bgp-btn-edit:hover {
|
|
1820
|
+
color: var(--color-primary);
|
|
1821
|
+
background: rgba(37, 99, 235, 0.1);
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
.bgp-btn-ungroup {
|
|
1825
|
+
color: var(--color-text-muted);
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
.bgp-btn-ungroup:hover {
|
|
1829
|
+
color: var(--color-error);
|
|
1830
|
+
background: rgba(239, 68, 68, 0.1);
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1833
|
+
.bgp-btn-save {
|
|
1834
|
+
color: var(--color-success);
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1837
|
+
.bgp-btn-save:hover {
|
|
1838
|
+
background: rgba(16, 185, 129, 0.1);
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1841
|
+
.bgp-btn-cancel {
|
|
1842
|
+
color: var(--color-text-muted);
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1845
|
+
.bgp-btn-cancel:hover {
|
|
1846
|
+
background: var(--color-bg-secondary);
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
/* Group edit inline form */
|
|
1850
|
+
.bgp-group-edit {
|
|
1851
|
+
display: flex;
|
|
1852
|
+
align-items: center;
|
|
1853
|
+
gap: 0.375rem;
|
|
1854
|
+
flex: 1;
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
.bgp-edit-input {
|
|
1858
|
+
padding: 0.1875rem 0.375rem;
|
|
1859
|
+
font-size: 0.7rem;
|
|
1860
|
+
border: 1px solid var(--color-border);
|
|
1861
|
+
border-radius: var(--radius-sm);
|
|
1862
|
+
background: var(--color-bg);
|
|
1863
|
+
color: var(--color-text);
|
|
1864
|
+
flex: 1;
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
.bgp-edit-input:focus {
|
|
1868
|
+
outline: none;
|
|
1869
|
+
border-color: var(--color-primary);
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1872
|
+
.bgp-edit-input-sm {
|
|
1873
|
+
max-width: 120px;
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1876
|
+
/* Create group controls */
|
|
1877
|
+
.bgp-create {
|
|
1878
|
+
padding: 0.5rem 0.75rem;
|
|
1879
|
+
margin-top: 0.375rem;
|
|
1880
|
+
border: 1px solid var(--color-border);
|
|
1881
|
+
border-radius: var(--radius-sm);
|
|
1882
|
+
background: var(--color-bg-secondary);
|
|
1883
|
+
display: flex;
|
|
1884
|
+
flex-direction: column;
|
|
1885
|
+
gap: 0.375rem;
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
.bgp-create-info {
|
|
1889
|
+
font-size: 0.75rem;
|
|
1890
|
+
font-weight: 500;
|
|
1891
|
+
color: var(--color-text);
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1894
|
+
.bgp-validation-error {
|
|
1895
|
+
color: var(--color-error);
|
|
1896
|
+
font-weight: 400;
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1899
|
+
.bgp-create-inputs {
|
|
1900
|
+
display: flex;
|
|
1901
|
+
gap: 0.375rem;
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1904
|
+
.bgp-create-class {
|
|
1905
|
+
flex: 2;
|
|
1906
|
+
padding: 0.25rem 0.5rem;
|
|
1907
|
+
font-size: 0.8rem;
|
|
1908
|
+
border: 1px solid var(--color-border);
|
|
1909
|
+
border-radius: var(--radius-sm);
|
|
1910
|
+
background: var(--color-bg);
|
|
1911
|
+
color: var(--color-text);
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
.bgp-create-class:focus {
|
|
1915
|
+
outline: none;
|
|
1916
|
+
border-color: var(--color-primary);
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
.bgp-create-label {
|
|
1920
|
+
flex: 1;
|
|
1921
|
+
padding: 0.25rem 0.5rem;
|
|
1922
|
+
font-size: 0.8rem;
|
|
1923
|
+
border: 1px solid var(--color-border);
|
|
1924
|
+
border-radius: var(--radius-sm);
|
|
1925
|
+
background: var(--color-bg);
|
|
1926
|
+
color: var(--color-text);
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1929
|
+
.bgp-create-label:focus {
|
|
1930
|
+
outline: none;
|
|
1931
|
+
border-color: var(--color-primary);
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1934
|
+
.bgp-create-actions {
|
|
1935
|
+
display: flex;
|
|
1936
|
+
gap: 0.375rem;
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
/* Hint when empty */
|
|
1940
|
+
.bgp-hint {
|
|
1941
|
+
padding: 0.75rem;
|
|
1942
|
+
font-size: 0.75rem;
|
|
1943
|
+
color: var(--color-text-muted);
|
|
1944
|
+
text-align: center;
|
|
1945
|
+
font-style: italic;
|
|
1946
|
+
}
|