feat: add drag-and-drop import for schedules (#661)
* testing * feat: add drag-and-drop support to FileUpload (#446) * chore: remove testing comment * chore: fix lint issues * chore: format FileUpload.tsx with prettier --------- Co-authored-by: Uthman Ogungbo <uthmanogungbo@Uthmans-MacBook-Pro.local> Co-authored-by: Uthman Ogungbo <uthmanogungbo@wireless-10-148-166-229.public.utexas.edu> Co-authored-by: Derek <derex1987@gmail.com>
This commit is contained in:
@@ -4,7 +4,7 @@ import type { ThemeColor } from '@shared/types/ThemeColors';
|
|||||||
import { getThemeColorHexByName, getThemeColorRgbByName } from '@shared/util/themeColors';
|
import { getThemeColorHexByName, getThemeColorRgbByName } from '@shared/util/themeColors';
|
||||||
import Text from '@views/components/common/Text/Text';
|
import Text from '@views/components/common/Text/Text';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import React from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -21,9 +21,8 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A reusable input button component that follows the Button.tsx consistency
|
* A reusable input button component that follows Button.tsx consistency.
|
||||||
*
|
* Now supports drag-and-drop file uploads (issue #446).
|
||||||
* @returns
|
|
||||||
*/
|
*/
|
||||||
export default function FileUpload({
|
export default function FileUpload({
|
||||||
className,
|
className,
|
||||||
@@ -43,22 +42,74 @@ export default function FileUpload({
|
|||||||
const isIconOnly = !children && !!icon;
|
const isIconOnly = !children && !!icon;
|
||||||
const colorHex = getThemeColorHexByName(color);
|
const colorHex = getThemeColorHexByName(color);
|
||||||
const colorRgb = getThemeColorRgbByName(color)?.join(' ');
|
const colorRgb = getThemeColorRgbByName(color)?.join(' ');
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
|
||||||
// Convert accept to a comma-separated string if it's an array
|
// Convert accept array to comma-separated list
|
||||||
const acceptValue = Array.isArray(accept) ? accept.join(',') : accept;
|
const acceptValue = Array.isArray(accept) ? accept.join(',') : accept;
|
||||||
|
|
||||||
|
// --- Prevent Chrome from opening the file on drop anywhere else ---
|
||||||
|
useEffect(() => {
|
||||||
|
const preventDefault = (e: DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('dragover', preventDefault);
|
||||||
|
window.addEventListener('drop', preventDefault);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('dragover', preventDefault);
|
||||||
|
window.removeEventListener('drop', preventDefault);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
// --- Local drag and drop handlers for this button only -------------
|
||||||
|
const handleDrop = (event: React.DragEvent<HTMLLabelElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
setIsDragging(false);
|
||||||
|
if (disabled) return;
|
||||||
|
|
||||||
|
const file = event.dataTransfer.files?.[0];
|
||||||
|
if (file && inputRef.current && onChange) {
|
||||||
|
const dataTransfer = new DataTransfer();
|
||||||
|
dataTransfer.items.add(file);
|
||||||
|
inputRef.current.files = dataTransfer.files;
|
||||||
|
|
||||||
|
// Trigger change event manually
|
||||||
|
onChange({ target: inputRef.current } as React.ChangeEvent<HTMLInputElement>);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (event: React.DragEvent<HTMLLabelElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
if (!disabled) setIsDragging(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragLeave = (event: React.DragEvent<HTMLLabelElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
setIsDragging(false);
|
||||||
|
};
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<label
|
<label
|
||||||
style={
|
onDrop={handleDrop}
|
||||||
{
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
style={{
|
||||||
...style,
|
...style,
|
||||||
color: disabled ? 'ut-gray' : colorHex,
|
color: disabled ? 'ut-gray' : colorHex,
|
||||||
backgroundColor: `rgb(${colorRgb} / var(--un-bg-opacity)`,
|
backgroundColor: `rgb(${colorRgb} / var(--un-bg-opacity))`,
|
||||||
} satisfies React.CSSProperties
|
}}
|
||||||
}
|
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'btn',
|
'btn transition-colors select-none',
|
||||||
{
|
{
|
||||||
|
'ring-2 ring-offset-2 ring-blue-400': isDragging && !disabled,
|
||||||
'text-white! bg-opacity-100 hover:enabled:shadow-md active:enabled:shadow-sm shadow-black/20':
|
'text-white! bg-opacity-100 hover:enabled:shadow-md active:enabled:shadow-sm shadow-black/20':
|
||||||
variant === 'filled',
|
variant === 'filled',
|
||||||
'bg-opacity-0 border-current hover:enabled:bg-opacity-8 border stroke-width-[1px]':
|
'bg-opacity-0 border-current hover:enabled:bg-opacity-8 border stroke-width-[1px]':
|
||||||
@@ -70,6 +121,7 @@ export default function FileUpload({
|
|||||||
'h-[35px] w-[35px] p-spacing-2': size === 'small' && isIconOnly,
|
'h-[35px] w-[35px] p-spacing-2': size === 'small' && isIconOnly,
|
||||||
'h-6 p-spacing-2': size === 'mini' && !isIconOnly,
|
'h-6 p-spacing-2': size === 'mini' && !isIconOnly,
|
||||||
'h-6 w-6 p-0': size === 'mini' && isIconOnly,
|
'h-6 w-6 p-0': size === 'mini' && isIconOnly,
|
||||||
|
'opacity-60 cursor-not-allowed': disabled,
|
||||||
},
|
},
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
@@ -85,6 +137,7 @@ export default function FileUpload({
|
|||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
<input
|
<input
|
||||||
|
ref={inputRef}
|
||||||
type='file'
|
type='file'
|
||||||
{...(accept ? { accept: acceptValue } : {})}
|
{...(accept ? { accept: acceptValue } : {})}
|
||||||
className='hidden'
|
className='hidden'
|
||||||
|
|||||||
@@ -175,11 +175,11 @@ export default function HeadingAndActions({ course, activeSchedule, onClose }: H
|
|||||||
description: (
|
description: (
|
||||||
<>
|
<>
|
||||||
The section you're adding is for{' '}
|
The section you're adding is for{' '}
|
||||||
<span className='text-ut-burntorange whitespace-nowrap'>
|
<span className='whitespace-nowrap text-ut-burntorange'>
|
||||||
{course.semester.season} {course.semester.year}
|
{course.semester.season} {course.semester.year}
|
||||||
</span>
|
</span>
|
||||||
, but your current schedule contains sections in{' '}
|
, but your current schedule contains sections in{' '}
|
||||||
<span className='text-ut-burntorange whitespace-nowrap'>{activeSemesters}</span>. Mixing
|
<span className='whitespace-nowrap text-ut-burntorange'>{activeSemesters}</span>. Mixing
|
||||||
semesters in one schedule may cause confusion.
|
semesters in one schedule may cause confusion.
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -232,21 +232,18 @@ export default function Settings(): JSX.Element {
|
|||||||
|
|
||||||
const handleImportClick = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleImportClick = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = event.target.files?.[0];
|
const file = event.target.files?.[0];
|
||||||
if (file) {
|
if (!file) return;
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = async e => {
|
|
||||||
try {
|
|
||||||
const result = e.target?.result as string;
|
|
||||||
const jsonObject = JSON.parse(result);
|
|
||||||
await importSchedule(jsonObject);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Invalid import file!');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
reader.readAsText(file);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
try {
|
||||||
|
const text = await file.text();
|
||||||
|
const data = JSON.parse(text);
|
||||||
|
await importSchedule(data);
|
||||||
|
alert('Schedule imported successfully.');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error importing schedule:', error);
|
||||||
|
alert('Failed to import schedule. Make sure the file is a valid .json format.');
|
||||||
|
}
|
||||||
|
};
|
||||||
// const handleAddCourseByLink = async () => {
|
// const handleAddCourseByLink = async () => {
|
||||||
// // todo: Use a proper modal instead of a prompt
|
// // todo: Use a proper modal instead of a prompt
|
||||||
// const link: string | null = prompt('Enter course link');
|
// const link: string | null = prompt('Enter course link');
|
||||||
|
|||||||
Reference in New Issue
Block a user