diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 7b17f9f..0703430 100755 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -4,6 +4,8 @@ import assert from 'node:assert'; import { dirname } from 'node:path'; import qrcode from 'qrcode-terminal'; import { fileURLToPath } from 'node:url'; +//import fs from 'fs'; +//import path from 'path'; import App, { parseArgs } from '@ezshare/lib'; diff --git a/packages/lib/src/index.ts b/packages/lib/src/index.ts index 2df2a2c..622d24a 100644 --- a/packages/lib/src/index.ts +++ b/packages/lib/src/index.ts @@ -121,6 +121,28 @@ export default ({ sharedPath: sharedPathIn, port, maxUploadSize, zipCompressionL }); })); +app.delete('/api/delete', asyncHandler(async (req, res) => { + const { path: filePath } = req.query; + + // Ensure path is provided and is a string + assert(typeof filePath === 'string', 'Path must be a string'); + + // Use existing helper to resolve path and check security (prevents directory traversal) + const absPath = await getFileAbsPath(filePath); + + // prevent deleting the root shared folder + if (absPath === sharedPath) { + res.status(403).json({ error: 'Cannot delete root directory' }); + return; + } + + console.log('Deleting file:', absPath); + await fs.unlink(absPath); + + res.json({ success: true }); + })); + + // NOTE: Must support non latin characters app.post('/api/paste', bodyParser.urlencoded({ extended: false }), asyncHandler(async (req, res) => { // eslint-disable-next-line unicorn/prefer-ternary diff --git a/packages/web/src/routes/dir/$dirId.tsx b/packages/web/src/routes/dir/$dirId.tsx index 0842e4b..702f526 100644 --- a/packages/web/src/routes/dir/$dirId.tsx +++ b/packages/web/src/routes/dir/$dirId.tsx @@ -2,11 +2,11 @@ import { createFileRoute, Outlet, Link, useNavigate } from '@tanstack/react-rout import axios from 'axios'; import { useState, useCallback, useMemo, useEffect, ReactNode, CSSProperties, ChangeEventHandler, ClipboardEvent } from 'react'; -import { FaFileArchive, FaFileDownload, FaFileAlt, FaFolder, FaSpinner, FaShareAlt, FaRedoAlt } from 'react-icons/fa'; +import { FaFileArchive, FaFileDownload, FaFileAlt, FaFolder, FaSpinner, FaShareAlt, FaRedoAlt, FaTrash } from 'react-icons/fa'; import Clipboard from 'react-clipboard.js'; import { motion, AnimatePresence } from 'framer-motion'; -import { colorLink, colorLink2, getDownloadUrl, getThumbUrl, headingBackgroundColor, mightBeImage, mightBeVideo, rootPath, Toast, CurrentDir, Context } from '../../util'; +import { colorLink, colorLink2, getDownloadUrl, getThumbUrl, headingBackgroundColor, mightBeImage, mightBeVideo, mightBeText, rootPath, Toast, CurrentDir, Context } from '../../util'; import Uploader from '../../Uploader'; @@ -42,15 +42,18 @@ const ZipDownload = ({ url, title = 'Download folder as ZIP', style }: { url: st const iconSize = '2em'; const iconStyle = { flexShrink: 0, color: 'rgba(0,0,0,0.5)', marginRight: '.5em', width: iconSize, height: iconSize }; -function FileRow({ path, isDir, fileName, onCheckedChange, checked, onFileClick }: { +function FileRow({ path, isDir, fileName, onCheckedChange, checked, onFileClick, onDelete }: { path: string, isDir: boolean, fileName: string, onCheckedChange?: ChangeEventHandler, checked?: boolean | undefined, onFileClick?: () => void, + onDelete?: (path: string) => void, }) { + const mightBeMedia = mightBeVideo({ isDir, fileName }) || mightBeImage({ isDir, fileName }); + const isClickable = mightBeMedia || mightBeText({ isDir, fileName }); function renderIcon() { if (mightBeMedia) { @@ -87,7 +90,7 @@ function FileRow({ path, isDir, fileName, onCheckedChange, checked, onFileClick ) : ( <> {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, react/jsx-props-no-spreading */} -
onFileClick?.() })}>{fileName}
+
onFileClick?.() })}>{fileName}
@@ -95,6 +98,15 @@ function FileRow({ path, isDir, fileName, onCheckedChange, checked, onFileClick )} + + {onDelete && ( + onDelete(path)} + /> + )} )}
@@ -143,6 +155,20 @@ function Browser() { const handleRefreshClick = useCallback(() => { loadCurrentPath(); }, [loadCurrentPath]); + + const handleDelete = useCallback(async (path: string) => { + // eslint-disable-next-line no-alert + if (!window.confirm(`Are you sure you want to delete this file?`)) return; + + try { + await axios.delete('/api/delete', { params: { path } }); + Toast.fire({ icon: 'success', title: 'File deleted successfully' }); + loadCurrentPath(); + } catch (err) { + console.error(err); + Toast.fire({ icon: 'error', title: 'Failed to delete file' }); + } + }, [loadCurrentPath]); const dirs = useMemo(() => currentDir.files.filter((f) => f.isDir), [currentDir.files]); const nonDirs = useMemo(() => currentDir.files.filter((f) => !f.isDir), [currentDir.files]); @@ -279,12 +305,9 @@ function Browser() { {nonDirs.map((props) => { const { path } = props; // eslint-disable-next-line react/jsx-props-no-spreading - return handleFileSelect(path, e.target.checked)} onFileClick={() => navigate({ to: './file', search: { p: path } })} />; + return handleFileSelect(path, e.target.checked)} onFileClick={() => navigate({ to: './file', search: { p: path } })} onDelete={handleDelete} />; })} - - {/* eslint-disable-next-line jsx-a11y/accessible-emoji */} -
More apps by mifi.no ❤️
diff --git a/packages/web/src/routes/dir/$dirId/file.tsx b/packages/web/src/routes/dir/$dirId/file.tsx index d23692d..3923588 100644 --- a/packages/web/src/routes/dir/$dirId/file.tsx +++ b/packages/web/src/routes/dir/$dirId/file.tsx @@ -2,8 +2,9 @@ import { createFileRoute, useNavigate, useRouter, useSearch } from '@tanstack/re import z from 'zod'; import { useState, useEffect, useCallback, CSSProperties, useRef, KeyboardEventHandler, WheelEventHandler, MouseEventHandler, ReactEventHandler, useMemo } from 'react'; import { FaList, FaTimes, FaVolumeMute, FaVolumeUp } from 'react-icons/fa'; +import axios from 'axios'; -import { getDownloadUrl, mightBeImage, mightBeVideo, useContext } from '../../../util'; +import { getDownloadUrl, mightBeImage, mightBeVideo, mightBeText, useContext } from '../../../util'; import styles from './file.module.css'; // eslint-disable-next-line import/prefer-default-export @@ -19,7 +20,10 @@ const buttonStyle: CSSProperties = { all: 'unset', padding: '.1em .3em', cursor: function ViewingFile() { const { currentDir } = useContext(); - const playableFiles = useMemo(() => currentDir.files.filter((f) => !f.isDir && (mightBeVideo(f) || mightBeImage(f))), [currentDir.files]); + + // 1. Updated playableFiles to include text files for Next/Prev navigation + const playableFiles = useMemo(() => currentDir.files.filter((f) => !f.isDir && (mightBeVideo(f) || mightBeImage(f) || mightBeText(f))), [currentDir.files]); + const { p: path } = useSearch({ from: Route.fullPath }); const navigate = useNavigate({ from: Route.fullPath }); @@ -35,6 +39,14 @@ function ViewingFile() { const [canPlayVideo, setCanPlayVideo] = useState(false); const [videoError, setVideoError] = useState(null); + // 2. Added specific states for Text files + const [textData, setTextData] = useState(null); + const [isLoadingText, setIsLoadingText] = useState(false); + + const isVideo = useMemo(() => viewingFile != null && mightBeVideo(viewingFile), [viewingFile]); + const isImage = useMemo(() => viewingFile != null && mightBeImage(viewingFile), [viewingFile]); + const isText = useMemo(() => viewingFile != null && mightBeText(viewingFile), [viewingFile]); + const setRelViewingFile = useCallback((rel: number) => { const navigatePath = (f: { path: string } | undefined) => { if (f == null) return; @@ -58,9 +70,7 @@ function ViewingFile() { const mediaRef = useRef(null); - const isVideo = useMemo(() => viewingFile != null && mightBeVideo(viewingFile), [viewingFile]); - const isImage = useMemo(() => viewingFile != null && mightBeImage(viewingFile), [viewingFile]); - + // UseEffect for Image/Video playback logic useEffect(() => { if (mediaRef.current) { mediaRef.current.focus({ preventScroll: true }); @@ -73,10 +83,8 @@ function ViewingFile() { if (isImage && playlistMode) { const slideTime = 5000; const startTime = Date.now(); - let t: number | undefined; - // ken burns zoom const animation = mediaRef.current?.animate([ { transform: 'scale(1)', offset: 0 }, { transform: 'scale(1.05)', offset: 1 }, @@ -88,7 +96,6 @@ function ViewingFile() { const tick = () => { t = setTimeout(() => { const now = Date.now(); - const p = Math.max(0, Math.min(1, (now - startTime) / slideTime)); setProgress(p); @@ -111,22 +118,39 @@ function ViewingFile() { return undefined; }, [handleNext, isImage, playlistMode, viewingFile]); + // 3. New UseEffect specifically for fetching Text files + useEffect(() => { + if (isText && viewingFile) { + setIsLoadingText(true); + setTextData("Loading file content..."); + + axios.get(getDownloadUrl(viewingFile.path), { responseType: 'text' }) + .then((response) => { + let content = response.data; + if (typeof content !== 'string') { + content = JSON.stringify(content, null, 2); + } + setTextData(content || '(File is empty)'); + }) + .catch((err) => { + console.error("Failed to load text file", err); + setTextData(`Error loading file content: ${err.message}`); + }) + .finally(() => { + setIsLoadingText(false); + }); + } + }, [isText, viewingFile]); + const handleKeyDown = useCallback>((e) => { if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; - // eslint-disable-next-line unicorn/prefer-switch if (e.key === 'ArrowLeft') { - e.preventDefault(); - e.stopPropagation(); - handlePrev(); + e.preventDefault(); e.stopPropagation(); handlePrev(); } else if (e.key === 'ArrowRight') { - e.preventDefault(); - e.stopPropagation(); - handleNext(); + e.preventDefault(); e.stopPropagation(); handleNext(); } else if (e.key === 'Escape') { - e.preventDefault(); - e.stopPropagation(); - handleClose(); + e.preventDefault(); e.stopPropagation(); handleClose(); } }, [handleClose, handleNext, handlePrev]); @@ -136,23 +160,19 @@ function ViewingFile() { const handlePointerUp = useCallback>((e) => { if (pointerStartX.current == null) return; - const diff = pointerStartX.current - e.clientX; if (Math.abs(diff) > 50) { if (diff > 0) handleNext(); else handlePrev(); } - pointerStartX.current = undefined; }, [handleNext, handlePrev]); const handleWheel = useCallback>((e) => { - // Trackpad horizontal swipes come as wheel events if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) { - if (Date.now() - lastWheel.current < 500) return; // ignore if too fast + if (Date.now() - lastWheel.current < 500) return; lastWheel.current = Date.now(); e.preventDefault(); - if (e.deltaX > 0) handleNext(); else handlePrev(); } @@ -161,56 +181,40 @@ function ViewingFile() { const handleClick = useCallback>((e) => { e.stopPropagation(); if (mediaRef.current != null && 'play' in mediaRef.current) { - if (mediaRef.current.paused) { - mediaRef.current.play(); - } else { - mediaRef.current.pause(); - } + if (mediaRef.current.paused) mediaRef.current.play(); + else mediaRef.current.pause(); } }, []); const handlePrevClick = useCallback>((e) => { - e.preventDefault(); - e.stopPropagation(); - handlePrev(); + e.preventDefault(); e.stopPropagation(); handlePrev(); }, [handlePrev]); const handleNextClick = useCallback>((e) => { - e.preventDefault(); - e.stopPropagation(); - handleNext(); + e.preventDefault(); e.stopPropagation(); handleNext(); }, [handleNext]); const handlePlaylistModeClick = useCallback>((e) => { - e.stopPropagation(); - setPlaylistMode((v) => !v); + e.stopPropagation(); setPlaylistMode((v) => !v); }, []); const handleMuteClick = useCallback>((e) => { - e.stopPropagation(); - setMuted((v) => !v); + e.stopPropagation(); setMuted((v) => !v); }, []); const handleVideoEnded = useCallback>(() => { - if (playlistMode) { - handleNext(); - } + if (playlistMode) handleNext(); }, [handleNext, playlistMode]); const scrubbingRef = useRef(false); const handleScrubDown = useCallback>((e) => { - e.preventDefault(); - e.stopPropagation(); - scrubbingRef.current = true; + e.preventDefault(); e.stopPropagation(); scrubbingRef.current = true; }, []); const handleScrubUp = useCallback>((e) => { - e.preventDefault(); - e.stopPropagation(); - scrubbingRef.current = false; + e.preventDefault(); e.stopPropagation(); scrubbingRef.current = false; }, []); const handleScrub = useCallback>((e) => { - e.preventDefault(); - e.stopPropagation(); + e.preventDefault(); e.stopPropagation(); if (!scrubbingRef.current) return; const target = e.target as HTMLDivElement; const p = e.clientX / target.clientWidth; @@ -226,50 +230,26 @@ function ViewingFile() { }, []); const handleVideoError = useCallback>((e) => { - if (e.target instanceof HTMLVideoElement) { - setVideoError(e.target.error); - } - + if (e.target instanceof HTMLVideoElement) setVideoError(e.target.error); if (playlistMode) { - setTimeout(() => { - handleNext(); - }, 300); + setTimeout(() => { handleNext(); }, 300); } }, [handleNext, playlistMode]); function renderPreview() { - if (viewingFile == null) { - return null; - } + if (viewingFile == null) return null; if (isVideo) { return ( <> {/* eslint-disable-next-line jsx-a11y/media-has-caption */}