mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-10-05 22:05:50 +01:00
Direct download tweaks (#408)
- Simplified bypasser process, removed warmup functionality - Added dedicated fast download sources, tried first.
This commit is contained in:
@@ -15,6 +15,9 @@ interface OrderableListFieldProps {
|
||||
// Represents where the drop indicator should appear
|
||||
type DropPosition = { index: number; position: 'before' | 'after' } | null;
|
||||
|
||||
// Merged item type with all properties
|
||||
type MergedItem = OrderableListItem & OrderableListOption;
|
||||
|
||||
/**
|
||||
* Merge current value with options to get full item info.
|
||||
* Items in value take precedence; any options not in value are appended.
|
||||
@@ -22,9 +25,9 @@ type DropPosition = { index: number; position: 'before' | 'after' } | null;
|
||||
const mergeValueWithOptions = (
|
||||
value: OrderableListItem[],
|
||||
options: OrderableListOption[]
|
||||
): Array<OrderableListItem & OrderableListOption> => {
|
||||
): MergedItem[] => {
|
||||
const optionsMap = new Map(options.map((opt) => [opt.id, opt]));
|
||||
const result: Array<OrderableListItem & OrderableListOption> = [];
|
||||
const result: MergedItem[] = [];
|
||||
|
||||
// Add items from value (preserves order)
|
||||
for (const item of value) {
|
||||
@@ -35,7 +38,7 @@ const mergeValueWithOptions = (
|
||||
}
|
||||
}
|
||||
|
||||
// Add any remaining options not in value (shouldn't happen normally)
|
||||
// Add any remaining options not in value
|
||||
for (const option of optionsMap.values()) {
|
||||
result.push({ ...option, id: option.id, enabled: false });
|
||||
}
|
||||
@@ -56,12 +59,24 @@ export const OrderableListField = ({
|
||||
|
||||
const items = mergeValueWithOptions(value ?? [], field.options);
|
||||
|
||||
// Check if a move is valid (not crossing pinned items)
|
||||
const isValidMove = (fromIndex: number): boolean => {
|
||||
const fromItem = items[fromIndex];
|
||||
if (fromItem?.isPinned) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleDragStart = (e: React.DragEvent, index: number) => {
|
||||
const item = items[index];
|
||||
if (item?.isPinned) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
setDraggedIndex(index);
|
||||
dragNodeRef.current = e.currentTarget as HTMLDivElement;
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', String(index));
|
||||
// Add a slight delay before adding the dragging class for better visual feedback
|
||||
requestAnimationFrame(() => {
|
||||
if (dragNodeRef.current) {
|
||||
dragNodeRef.current.classList.add('opacity-50');
|
||||
@@ -85,7 +100,12 @@ export const OrderableListField = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine if we're in the top or bottom half of the target
|
||||
// Can't drop on pinned items
|
||||
if (items[index]?.isPinned) {
|
||||
setDropPosition(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const midpoint = rect.top + rect.height / 2;
|
||||
const position = e.clientY < midpoint ? 'before' : 'after';
|
||||
@@ -94,7 +114,6 @@ export const OrderableListField = ({
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
// Only clear if we're leaving the item entirely (not entering a child)
|
||||
const relatedTarget = e.relatedTarget as Node | null;
|
||||
if (!e.currentTarget.contains(relatedTarget)) {
|
||||
setDropPosition(null);
|
||||
@@ -108,27 +127,23 @@ export const OrderableListField = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate the actual target index based on drop position
|
||||
let targetIndex = dropPosition.index;
|
||||
if (dropPosition.position === 'after') {
|
||||
targetIndex += 1;
|
||||
}
|
||||
// Adjust if dragging from before the target
|
||||
if (draggedIndex < targetIndex) {
|
||||
targetIndex -= 1;
|
||||
}
|
||||
|
||||
if (draggedIndex === targetIndex) {
|
||||
if (draggedIndex === targetIndex || !isValidMove(draggedIndex)) {
|
||||
handleDragEnd();
|
||||
return;
|
||||
}
|
||||
|
||||
// Reorder the items
|
||||
const newItems = [...items];
|
||||
const [removed] = newItems.splice(draggedIndex, 1);
|
||||
newItems.splice(targetIndex, 0, removed);
|
||||
|
||||
// Convert back to value format
|
||||
const newValue: OrderableListItem[] = newItems.map((item) => ({
|
||||
id: item.id,
|
||||
enabled: item.enabled,
|
||||
@@ -154,6 +169,7 @@ export const OrderableListField = ({
|
||||
const moveItem = (fromIndex: number, direction: 'up' | 'down') => {
|
||||
const toIndex = direction === 'up' ? fromIndex - 1 : fromIndex + 1;
|
||||
if (toIndex < 0 || toIndex >= items.length) return;
|
||||
if (!isValidMove(fromIndex)) return;
|
||||
|
||||
const newItems = [...items];
|
||||
[newItems[fromIndex], newItems[toIndex]] = [newItems[toIndex], newItems[fromIndex]];
|
||||
@@ -166,14 +182,26 @@ export const OrderableListField = ({
|
||||
onChange(newValue);
|
||||
};
|
||||
|
||||
// Calculate which gap index to show the indicator at (0 = before first item, N = after last item)
|
||||
const canMoveUp = (index: number): boolean => {
|
||||
const item = items[index];
|
||||
if (item?.isPinned) return false;
|
||||
if (index === 0) return false;
|
||||
if (items[index - 1]?.isPinned) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
const canMoveDown = (index: number): boolean => {
|
||||
const item = items[index];
|
||||
if (item?.isPinned) return false;
|
||||
if (index === items.length - 1) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
const getDropGapIndex = (): number | null => {
|
||||
if (!dropPosition) return null;
|
||||
if (dropPosition.position === 'before') {
|
||||
return dropPosition.index;
|
||||
} else {
|
||||
return dropPosition.index + 1;
|
||||
}
|
||||
return dropPosition.position === 'before'
|
||||
? dropPosition.index
|
||||
: dropPosition.index + 1;
|
||||
};
|
||||
|
||||
const dropGapIndex = getDropGapIndex();
|
||||
@@ -183,137 +211,131 @@ export const OrderableListField = ({
|
||||
{items.map((item, index) => {
|
||||
const isDragging = draggedIndex === index;
|
||||
const isItemDisabled = isDisabled || item.isLocked;
|
||||
// Show indicator before this item if the gap index matches
|
||||
const isPinned = item.isPinned ?? false;
|
||||
const showIndicatorBefore = dropGapIndex === index;
|
||||
|
||||
return (
|
||||
<div key={item.id} className="relative">
|
||||
{/* Drop indicator - absolutely positioned so it doesn't affect layout */}
|
||||
{/* Drop indicator */}
|
||||
{showIndicatorBefore && (
|
||||
<div className="absolute left-1 right-1 h-1 bg-sky-500 rounded-full z-10 -top-1 -translate-y-1/2" />
|
||||
)}
|
||||
|
||||
<div
|
||||
draggable
|
||||
draggable={!isPinned}
|
||||
onDragStart={(e) => handleDragStart(e, index)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={(e) => handleDragOver(e, index)}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
className={`
|
||||
flex items-center gap-3 p-3 rounded-lg border
|
||||
flex items-center gap-3 p-3 rounded-lg
|
||||
transition-all duration-150
|
||||
${isDragging ? 'opacity-50 cursor-grabbing' : 'cursor-grab'}
|
||||
border-[var(--border-muted)]
|
||||
${isDisabled ? 'opacity-60' : 'hover:bg-[var(--hover-surface)]'}
|
||||
${isDragging ? 'opacity-50 cursor-grabbing' : isPinned ? 'cursor-default' : 'cursor-grab'}
|
||||
border border-[var(--border-muted)]
|
||||
${isDisabled ? 'opacity-60' : !isPinned ? 'hover:bg-[var(--hover-surface)]' : ''}
|
||||
`}
|
||||
>
|
||||
{/* Reorder Controls */}
|
||||
<div className="flex flex-col flex-shrink-0 -my-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
moveItem(index, 'up');
|
||||
}}
|
||||
disabled={index === 0}
|
||||
className={`
|
||||
p-1.5 sm:p-0.5 rounded transition-colors
|
||||
${index === 0
|
||||
? 'text-gray-300 dark:text-gray-600 cursor-not-allowed'
|
||||
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 sm:hover:bg-gray-100 sm:dark:hover:bg-gray-700'
|
||||
}
|
||||
`}
|
||||
aria-label="Move up"
|
||||
>
|
||||
<svg className="w-5 h-5 sm:w-4 sm:h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 15l7-7 7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
moveItem(index, 'down');
|
||||
}}
|
||||
disabled={index === items.length - 1}
|
||||
className={`
|
||||
p-1.5 sm:p-0.5 rounded transition-colors
|
||||
${index === items.length - 1
|
||||
? 'text-gray-300 dark:text-gray-600 cursor-not-allowed'
|
||||
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 sm:hover:bg-gray-100 sm:dark:hover:bg-gray-700'
|
||||
}
|
||||
`}
|
||||
aria-label="Move down"
|
||||
>
|
||||
<svg className="w-5 h-5 sm:w-4 sm:h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Label and Description */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-sm">{item.label}</div>
|
||||
{item.description && (
|
||||
<div className="text-xs text-[var(--text-muted)] mt-0.5">
|
||||
{item.description}
|
||||
</div>
|
||||
)}
|
||||
{item.isLocked && item.disabledReason && (
|
||||
<div className="text-xs text-amber-500 mt-0.5 flex items-center gap-1">
|
||||
<svg
|
||||
className="w-3 h-3"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{item.disabledReason}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Toggle Switch */}
|
||||
{(() => {
|
||||
// Locked items always show as "off" regardless of enabled state
|
||||
const showAsEnabled = item.enabled && !item.isLocked;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={showAsEnabled}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleItem(index);
|
||||
}}
|
||||
disabled={isItemDisabled}
|
||||
className={`
|
||||
relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full
|
||||
transition-colors duration-200 focus:outline-none focus:ring-2
|
||||
focus:ring-sky-500/50 disabled:opacity-60 disabled:cursor-not-allowed
|
||||
${showAsEnabled ? 'bg-sky-600' : 'bg-gray-300 dark:bg-gray-600'}
|
||||
`}
|
||||
>
|
||||
<span
|
||||
{/* Reorder Controls - hidden for pinned items */}
|
||||
{!isPinned ? (
|
||||
<div className="flex flex-col flex-shrink-0 -my-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
moveItem(index, 'up');
|
||||
}}
|
||||
disabled={!canMoveUp(index)}
|
||||
className={`
|
||||
inline-block h-4 w-4 transform rounded-full bg-white
|
||||
shadow-sm transition-transform duration-200
|
||||
${showAsEnabled ? 'translate-x-6' : 'translate-x-1'}
|
||||
p-1.5 sm:p-0.5 rounded transition-colors
|
||||
${!canMoveUp(index)
|
||||
? 'text-gray-300 dark:text-gray-600 cursor-not-allowed'
|
||||
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 sm:hover:bg-gray-100 sm:dark:hover:bg-gray-700'
|
||||
}
|
||||
`}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
aria-label="Move up"
|
||||
>
|
||||
<svg className="w-5 h-5 sm:w-4 sm:h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 15l7-7 7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
moveItem(index, 'down');
|
||||
}}
|
||||
disabled={!canMoveDown(index)}
|
||||
className={`
|
||||
p-1.5 sm:p-0.5 rounded transition-colors
|
||||
${!canMoveDown(index)
|
||||
? 'text-gray-300 dark:text-gray-600 cursor-not-allowed'
|
||||
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 sm:hover:bg-gray-100 sm:dark:hover:bg-gray-700'
|
||||
}
|
||||
`}
|
||||
aria-label="Move down"
|
||||
>
|
||||
<svg className="w-5 h-5 sm:w-4 sm:h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-5 sm:w-4 flex-shrink-0" />
|
||||
)}
|
||||
|
||||
{/* Label and Description */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-sm">{item.label}</div>
|
||||
{item.description && (
|
||||
<div className="text-xs text-[var(--text-muted)] mt-0.5">
|
||||
{item.description}
|
||||
</div>
|
||||
)}
|
||||
{item.isLocked && item.disabledReason && (
|
||||
<div className="text-xs text-amber-500 mt-0.5 flex items-center gap-1">
|
||||
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{item.disabledReason}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Toggle Switch */}
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={item.enabled && !item.isLocked}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleItem(index);
|
||||
}}
|
||||
disabled={isItemDisabled}
|
||||
className={`
|
||||
relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full
|
||||
transition-colors duration-200 focus:outline-none focus:ring-2
|
||||
focus:ring-sky-500/50 disabled:opacity-60 disabled:cursor-not-allowed
|
||||
${item.enabled && !item.isLocked ? 'bg-sky-600' : 'bg-gray-300 dark:bg-gray-600'}
|
||||
`}
|
||||
>
|
||||
<span
|
||||
className={`
|
||||
inline-block h-4 w-4 transform rounded-full bg-white
|
||||
shadow-sm transition-transform duration-200
|
||||
${item.enabled && !item.isLocked ? 'translate-x-6' : 'translate-x-1'}
|
||||
`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{/* Drop indicator after last item - use relative container with absolute indicator */}
|
||||
{/* Drop indicator after last item */}
|
||||
{dropGapIndex === items.length && (
|
||||
<div className="relative h-0">
|
||||
<div className="absolute left-1 right-1 h-1 bg-sky-500 rounded-full z-10 -top-0.5" />
|
||||
|
||||
@@ -101,6 +101,7 @@ export interface OrderableListOption {
|
||||
description?: string;
|
||||
disabledReason?: string; // Explanation when item cannot be enabled
|
||||
isLocked?: boolean; // Item cannot be toggled (e.g., missing dependency)
|
||||
isPinned?: boolean; // Item cannot be reordered (but can still be toggled if not locked)
|
||||
}
|
||||
|
||||
export interface OrderableListFieldConfig extends BaseField {
|
||||
|
||||
Reference in New Issue
Block a user