initial commit after project creation

This commit is contained in:
Gerhard Scheikl
2026-04-01 09:38:50 +02:00
commit b02af637d4
292 changed files with 61408 additions and 0 deletions

View File

@@ -0,0 +1,294 @@
"use client";
import {
ChevronDownIcon,
ChevronRightIcon,
DocumentIcon,
FolderIcon,
TrashIcon,
} from "@heroicons/react/24/outline";
import { useState } from "react";
import React from "react";
import type { DragEvent, KeyboardEvent } from "react";
// Types
export interface FileItem {
id: string;
name: string;
type: "file" | "folder";
parentId: string | null;
}
export interface TreeNode extends FileItem {
children: TreeNode[];
level: number;
}
export interface DragState {
draggedId: string | null;
dragOverId: string | null;
dropPosition: "before" | "after" | "inside" | null;
}
export interface EditState {
editingId: string | null;
setEditingId: (id: string | null) => void;
}
export interface DragActions {
dragState: DragState;
setDragState: (state: DragState) => void;
}
export interface TreeState {
expandedFolders: Set<string>;
toggleFolder: (id: string) => void;
}
export interface FileTreeItemProps {
node: TreeNode;
editState: EditState;
dragActions: DragActions;
treeState: TreeState;
onUpdate: (id: string, updates: Partial<FileItem>) => void;
onDelete: (id: string) => void;
onMove: (
draggedId: string,
targetId: string | null,
position: "before" | "after" | "inside"
) => void;
}
// Constants for drag and drop
export const DRAG_STATE_RESET: DragState = {
draggedId: null,
dragOverId: null,
dropPosition: null,
};
const DROP_ZONES = {
BEFORE_THRESHOLD: 0.25,
AFTER_THRESHOLD: 0.75,
FILE_MIDDLE_THRESHOLD: 0.5,
} as const;
const SPACING = {
LEVEL_INDENT: 20,
BASE_PADDING: 8,
} as const;
export const FileTreeItem = ({
node,
editState,
dragActions,
treeState,
onUpdate,
onDelete,
onMove,
}: FileTreeItemProps) => {
const { editingId, setEditingId } = editState;
const { dragState, setDragState } = dragActions;
const { expandedFolders, toggleFolder } = treeState;
const [editName, setEditName] = useState(node.name);
const isExpanded = expandedFolders.has(node.id);
const hasChildren = node.children.length > 0;
const isEditing = editingId === node.id;
const isDragging = dragState.draggedId === node.id;
const isDragOver = dragState.dragOverId === node.id;
const handleSave = () => {
if (editName.trim()) {
onUpdate(node.id, { name: editName.trim() });
setEditingId(null);
}
};
const handleCancel = () => {
setEditName(node.name);
setEditingId(null);
};
const handleKeyPress = (e: KeyboardEvent) => {
if (e.key === "Enter") handleSave();
else if (e.key === "Escape") handleCancel();
};
const handleDragStart = (e: DragEvent) => {
e.dataTransfer.setData("text/plain", node.id);
e.dataTransfer.effectAllowed = "move";
setDragState({ ...DRAG_STATE_RESET, draggedId: node.id });
};
const handleDragEnd = () => setDragState(DRAG_STATE_RESET);
const getDropPosition = (
e: DragEvent,
rect: DOMRect
): "before" | "after" | "inside" => {
const relativeY = (e.clientY - rect.top) / rect.height;
if (node.type === "folder") {
if (relativeY < DROP_ZONES.BEFORE_THRESHOLD) return "before";
if (relativeY > DROP_ZONES.AFTER_THRESHOLD) return "after";
return "inside";
}
return relativeY < DROP_ZONES.FILE_MIDDLE_THRESHOLD ? "before" : "after";
};
const handleDragOver = (e: DragEvent) => {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
if (!dragState.draggedId) return;
setDragState({
...dragState,
dragOverId: node.id,
dropPosition: getDropPosition(e, e.currentTarget.getBoundingClientRect()),
});
};
const handleDragLeave = (e: DragEvent) => {
// Only clear if we're leaving this element entirely
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
setDragState({
...dragState,
dragOverId: null,
dropPosition: null,
});
}
};
const handleDrop = (e: DragEvent) => {
e.preventDefault();
if (!dragState.draggedId || !dragState.dropPosition) return;
onMove(dragState.draggedId, node.id, dragState.dropPosition);
setDragState(DRAG_STATE_RESET);
};
const getDropIndicatorClass = () => {
if (!isDragOver || !dragState.dropPosition) return "";
const indicators = {
before: "border-t-2 border-blue-500",
after: "border-b-2 border-blue-500",
inside: "bg-blue-50 border border-blue-300",
};
return indicators[dragState.dropPosition] || "";
};
const leftPadding = node.level * SPACING.LEVEL_INDENT + SPACING.BASE_PADDING;
return (
<div>
<div
className={[
"flex items-center gap-2 py-1 px-2 hover:bg-gray-50 rounded text-sm group relative w-fit min-w-full",
!isEditing && "cursor-grab active:cursor-grabbing",
isDragging && "opacity-50",
getDropIndicatorClass(),
]
.filter(Boolean)
.join(" ")}
style={{
paddingLeft: `${leftPadding}px`,
}}
draggable={!isEditing}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{/* Expand/Collapse Icon */}
{node.type === "folder" && hasChildren && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
toggleFolder(node.id);
}}
className="flex-shrink-0 hover:bg-gray-200 rounded p-0.5"
>
{isExpanded ? (
<ChevronDownIcon className="h-3 w-3 text-gray-600" />
) : (
<ChevronRightIcon className="h-3 w-3 text-gray-600" />
)}
</button>
)}
{(node.type === "file" || !hasChildren) && (
<div className="w-4 flex-shrink-0" />
)}
{/* File/Folder Icon */}
<div className="flex-shrink-0">
{node.type === "folder" ? (
<FolderIcon className="h-4 w-4 text-blue-500" />
) : (
<DocumentIcon className="h-4 w-4 text-gray-500" />
)}
</div>
{/* Name */}
<div className="flex-1 min-w-0">
{isEditing ? (
<input
type="text"
value={editName}
onChange={(e) => setEditName(e.target.value)}
onBlur={handleSave}
onKeyDown={handleKeyPress}
className="w-full px-1 py-0.5 text-sm border border-blue-300 rounded focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
) : (
<span
className={`cursor-pointer ${node.type === "folder" ? "font-medium" : ""}`}
onClick={(e) => {
e.stopPropagation();
setEditingId(node.id);
}}
>
{node.name}
</span>
)}
</div>
{/* Actions */}
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onDelete(node.id);
}}
className="p-1 hover:bg-red-100 rounded"
title="Delete"
>
<TrashIcon className="h-3 w-3 text-red-600" />
</button>
</div>
</div>
{/* Children */}
{node.type === "folder" &&
isExpanded &&
node.children.map((child) => (
<FileTreeItem
// @ts-expect-error - key is not a prop of FileTreeItemProps, false positive
key={child.id}
node={child}
editState={{ editingId, setEditingId }}
dragActions={{ dragState, setDragState }}
treeState={{ expandedFolders, toggleFolder }}
onUpdate={onUpdate}
onDelete={onDelete}
onMove={onMove}
/>
))}
</div>
);
};