Merge pull request 'drag & drop' (#39) from update-categories into master
All checks were successful
Build and Publish Images / build-and-push (push) Successful in 21s

Reviewed-on: #39
This commit was merged in pull request #39.
This commit is contained in:
2026-05-16 17:12:39 +03:00
3 changed files with 168 additions and 5 deletions

View File

@@ -8,6 +8,9 @@
"name": "frontend",
"version": "0.0.0",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@tailwindcss/postcss": "^4.1.18",
"@tauri-apps/api": "^2.10.1",
"@tauri-apps/plugin-deep-link": "^2.4.7",
@@ -345,6 +348,59 @@
"node": ">=6.9.0"
}
},
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
@@ -4099,6 +4155,12 @@
"typescript": ">=4.8.4"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",

View File

@@ -10,6 +10,9 @@
"preview": "vite preview"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@tailwindcss/postcss": "^4.1.18",
"@tauri-apps/api": "^2.10.1",
"@tauri-apps/plugin-deep-link": "^2.4.7",

View File

@@ -24,8 +24,63 @@ import {
Check,
User,
Settings,
GripVertical,
} from 'lucide-react';
import ShoppingListModal from '../components/ShoppingListModal';
import {
DndContext,
closestCenter,
PointerSensor,
KeyboardSensor,
useSensor,
useSensors,
type DragEndEvent,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
function SortableItem({
id,
children,
}: {
id: number;
children: (props: { listeners: ReturnType<typeof useSortable>['listeners']; attributes: ReturnType<typeof useSortable>['attributes'] }) => React.ReactNode;
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id });
return (
<div
ref={setNodeRef}
style={{
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
zIndex: isDragging ? 10 : 'auto' as any,
position: 'relative',
}}
>
{children({ listeners, attributes })}
</div>
);
}
const getCategoryOrder = (fid: string): number[] => {
try {
const stored = localStorage.getItem(`cat_order_${fid}`);
return stored ? JSON.parse(stored) : [];
} catch {
return [];
}
};
const saveCategoryOrder = (fid: string, order: number[]) => {
localStorage.setItem(`cat_order_${fid}`, JSON.stringify(order));
};
export default function FamilyView() {
const { t } = useTranslation();
@@ -33,6 +88,11 @@ export default function FamilyView() {
const navigate = useNavigate();
const { selectedFamily } = useStore();
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
);
const [categories, setCategories] = useState<Category[]>([]);
const [remainingLimits, setRemainingLimits] = useState<Map<number, number>>(new Map());
const [loading, setLoading] = useState(true);
@@ -77,7 +137,19 @@ export default function FamilyView() {
console.log('Loading categories for family:', familyId);
const response = await categoryApi.getAllByFamily(parseInt(familyId));
console.log('Categories loaded:', response.data);
setCategories(response.data);
const savedOrder = getCategoryOrder(familyId);
if (savedOrder.length > 0) {
const sorted = [...response.data].sort((a, b) => {
const ai = savedOrder.indexOf(a.id);
const bi = savedOrder.indexOf(b.id);
if (ai === -1) return 1;
if (bi === -1) return -1;
return ai - bi;
});
setCategories(sorted);
} else {
setCategories(response.data);
}
const limits = new Map<number, number>();
for (const category of response.data) {
@@ -209,6 +281,18 @@ export default function FamilyView() {
}
};
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id || !familyId) return;
setCategories((prev) => {
const oldIndex = prev.findIndex(c => c.id === active.id);
const newIndex = prev.findIndex(c => c.id === over.id);
const reordered = arrayMove(prev, oldIndex, newIndex);
saveCategoryOrder(familyId, reordered.map(c => c.id));
return reordered;
});
};
const handleShowHistory = async (categoryId: number) => {
if (!familyId) return;
@@ -403,19 +487,29 @@ export default function FamilyView() {
</div>
)}
<div className="space-y-5 mb-6 max-w-3xl mx-auto">
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={categories.map(c => c.id)} strategy={verticalListSortingStrategy}>
<div className="space-y-5 mb-6 max-w-3xl mx-auto">
{categories.map((category) => {
const remaining = remainingLimits.get(category.id) || 0;
const limit = parseFloat(category.limit_amount.toString());
const percentage = getProgressPercentage(remaining, limit);
return (
<SortableItem key={category.id} id={category.id}>
{({ listeners, attributes }) => (
<div
key={category.id}
className="glass-effect rounded-2xl shadow-lg p-4 sm:p-5 card-hover"
>
<div className="flex items-center justify-between gap-3 mb-4">
<div className="flex items-center gap-3">
<button
className="cursor-grab active:cursor-grabbing p-1 text-gray-400 hover:text-gray-600 touch-none flex-shrink-0"
{...listeners}
{...attributes}
>
<GripVertical className="w-5 h-5" />
</button>
<div className="p-2 category-icon text-white rounded-xl shadow-lg">
<Tag className="w-6 h-6" />
</div>
@@ -428,7 +522,7 @@ export default function FamilyView() {
<div className="flex items-center gap-2">
<button
onClick={() => handleOpenEditCategory(category)}
className="p-2 bg-gray-200 hover:bg-gray-300 text-gray-600 rounded-xl transition-all duration-300"
className="p-2 bg-gray-100 hover:bg-gray-200 border border-gray-300 text-gray-500 hover:text-gray-700 rounded-xl transition-all duration-300 shadow-sm"
title={t('category.editTitle')}
>
<Settings className="w-4 h-4" />
@@ -729,9 +823,13 @@ export default function FamilyView() {
)}
</div>
)}
</SortableItem>
);
})}
</div>
</div>
</SortableContext>
</DndContext>
<div className="glass-effect rounded-3xl shadow-xl p-6 sm:p-8 max-w-3xl mx-auto">
<div className="flex items-center justify-center gap-3 mb-8">