Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 92 additions & 7 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,89 @@
import { Router, Route } from "@solidjs/router";
import { Router, Route, useLocation, useNavigate } from "@solidjs/router";
import type { RouteSectionProps } from "@solidjs/router";
import type { Component } from "solid-js";
import HomePage from "./pages/HomePage";
import { MyDrive } from "./pages/MyDrive";
import { useWebSocket, WebSocketProvider } from "./Websockets";
import MyCollections from "./pages/MyCollections";
import Account from "./pages/Account";
import { AppContext, ContextProvider } from "./Context";
import { createEffect, useContext } from "solid-js";
import { createEffect, useContext, onMount, onCleanup } from "solid-js";
import { UniversalMessageHandler } from "./library/functions";
import CollectionPage from "./pages/Collection";
import toast from "solid-toast";


// Mounted inside Router so router hooks are valid
let __globalPasteListenerAttached = false;
const GlobalPasteHandler = () => {
const navigate = useNavigate();
const location = useLocation();
const ctx = useContext(AppContext)!;
onMount(() => {
if (__globalPasteListenerAttached) return;
const pasteHandler = (e: ClipboardEvent) => {
try {
// If a page-level handler already handled it, skip
if (e.defaultPrevented) return;
// Ignore when typing in inputs/textarea/contenteditable
const ae = document.activeElement as HTMLElement | null;
if (ae && (ae.tagName === 'INPUT' || ae.tagName === 'TEXTAREA' || ae.isContentEditable)) return;

const dt = e.clipboardData;
if (!dt) return;
const fromFiles = dt.files ? Array.from(dt.files) : [];
const fromItems = dt.items ? Array.from(dt.items)
.filter(it => it.kind === 'file')
.map(it => it.getAsFile())
.filter((f): f is File => !!f && f.size > 0) : [];
// Merge and dedupe by name|size|type|lastModified to avoid duplicates
const seen = new Set<string>();
const files: File[] = [];
for (const f of [...fromFiles, ...fromItems]) {
const key = `${f.name}|${f.size}|${f.type}|${(f as any).lastModified ?? ''}`;
if (!seen.has(key)) { seen.add(key); files.push(f); }
}
if (files.length === 0) {
// Gracefully ignore pure text pastes globally
return;
}

// Prevent default so page-level handlers don't also process and browser doesn't paste images
e.preventDefault();

const path = location.pathname || "";
if (path.startsWith("/collection")) {
document.dispatchEvent(new CustomEvent("open-collection-upload", { detail: { files } }));
return;
}

const openDriveUpload = () => {
document.dispatchEvent(new CustomEvent("open-drive-upload", { detail: { files } }));
};

if (path !== "/my_drive") {
// Use context to pass pending files and route
ctx.setPendingDriveUploadFiles?.(files);
try { toast.success(`Opening My Drive to upload ${files.length} file${files.length>1?'s':''}…`); } catch {}
navigate("/my_drive");
} else {
openDriveUpload();
}
} catch (err) {
console.error('Global paste handler error:', err);
}
};

document.addEventListener('paste', pasteHandler);
__globalPasteListenerAttached = true;
onCleanup(() => {
document.removeEventListener('paste', pasteHandler);
__globalPasteListenerAttached = false;
});
});
return null;
}

const UncontextedApp = () => {
const { socket: getSocket } = useWebSocket();
const ctx = useContext(AppContext)!;
Expand All @@ -22,15 +96,26 @@ const UncontextedApp = () => {
})
return (
<Router>
<Route path="/" component={HomePage} />
<Route path="/my_drive" component={MyDrive}/>
<Route path="/my_collections" component={MyCollections}/>
<Route path="/account" component={Account}/>
<Route path="/collection/*" component={CollectionPage} />
<Route path="/" component={RootLayout}>
<Route path="/" component={HomePage} />
<Route path="/my_drive" component={MyDrive}/>
<Route path="/my_collections" component={MyCollections}/>
<Route path="/account" component={Account}/>
<Route path="/collection/*" component={CollectionPage} />
</Route>
</Router>
)
}

const RootLayout: Component<RouteSectionProps<unknown>> = (props) => {
return (
<>
<GlobalPasteHandler />
{props.children}
</>
);
}

const App = () => {
return (
<WebSocketProvider>
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/Context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const ContextProvider: ParentComponent = (props) => {
const [userCollections, setUserCollections] = createSignal<Set<string>>(new Set());
const [knownCollections, setKnownCollections] = createSignal<KnownCollections>({});
const [knownCollectionCards, setKnownCollectionCards] = createSignal<KnownCollectionCards>({});
const [pendingDriveUploadFiles, setPendingDriveUploadFiles] = createSignal<File[] | null>(null);
const contextValue: AppContextType = {
files: files,
setFiles: setFiles,
Expand All @@ -18,6 +19,8 @@ const ContextProvider: ParentComponent = (props) => {
setKnownCollections: setKnownCollections,
knownCollectionCards: knownCollectionCards,
setKnownCollectionCards: setKnownCollectionCards,
pendingDriveUploadFiles,
setPendingDriveUploadFiles,
};

return (
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/components/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ const DesktopNavbar: Component<{ CurrentPage: Pages }> = (props) => {
<GitHubSVG />
</a>
</div>
<div class="flex-grow" />
<div class="grow" />
<div class="w-full p-[16%]" onClick={() => {navigate("/account")}}>
<div class="hover:bg-black p-[20%] rounded-full">
<UserSVG />
Expand Down Expand Up @@ -130,7 +130,7 @@ const MobileNavbar: Component<{ CurrentPage: Pages }> = (props) => {
</div>
<span class="font-bold">GitHub</span>
</button>
<div class="flex-grow" />
<div class="grow" />
<button
class={`mb-5 w-full text-left px-4 py-2 flex items-center space-x-4 rounded-lg ${
props.CurrentPage === "Account" ? "bg-black text-white" : "hover:bg-[#242424] text-white"
Expand Down
8 changes: 5 additions & 3 deletions frontend/src/components/Template.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import type { Component } from "solid-js";
import { createSignal } from "solid-js";
import { createSignal, useContext } from "solid-js";
import { useLocation, useNavigate } from "@solidjs/router";
import Navbar from "./Navbar";
import type { Pages } from "../library/types";
import { AppContext } from "../Context";

const DesktopTemplate: Component<{ CurrentPage: Pages; children: any }> = (props) => {
const navigate = useNavigate();
const location = useLocation();
const ctx = useContext(AppContext)!;
const [isDragOver, setIsDragOver] = createSignal(false);

const handleDrop = (e: DragEvent) => {
Expand All @@ -28,8 +30,8 @@ const DesktopTemplate: Component<{ CurrentPage: Pages; children: any }> = (props
};

if (path !== "/my_drive") {
// Navigation case: stash pending and navigate. Target page will consume and open.
(window as any).__pendingDriveUploadFiles = files;
// Navigation case: stash pending in context and navigate. Target page will consume and open.
ctx.setPendingDriveUploadFiles?.(files);
navigate("/my_drive");
} else {
openDriveUpload();
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/library/types.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,9 @@ type AppContextType = {
setKnownCollections: (value: KnownCollections | ((prev: KnownCollections) => KnownCollections)) => void;
knownCollectionCards: () => KnownCollectionCards;
setKnownCollectionCards: (value: KnownCollectionCards | ((prev: KnownCollectionCards) => KnownCollectionCards)) => void;
// Pending files for My Drive uploads when navigation is required
pendingDriveUploadFiles?: () => File[] | null;
setPendingDriveUploadFiles?: (value: File[] | null | ((prev: File[] | null) => File[] | null)) => void;
};

export type {RAMData, CPUData, SysInfo, GraphData, IncomingData, SocketStatus, Pages, FileData, CollectionCardData, AppContextType, KnownCollections, KnownCollectionCards};
65 changes: 63 additions & 2 deletions frontend/src/pages/Collection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import Dialog from "@corvu/dialog";
import Dropdown from "../components/Dropdown";
import { getCollection, generateUUID } from "../library/functions";
import { Toaster } from "solid-toast";
import toast from "solid-toast";
import { CollectionCardData } from "../library/types";
import { uploadFileInChunks, FileUploadPreview, SelectableFile, FileUploadProgressData } from "./MyDrive";
import Navbar from "../components/Navbar";
Expand All @@ -24,6 +25,9 @@ const AddFilePopup: Component<{collectionId: string, isMobile?: boolean}> = (pro
const [modifying, setModifying] = createSignal<"existing" | "new" | null>(null);
const [isDragOver, setIsDragOver] = createSignal(false);
const [open, setOpen] = createSignal(false);
const [flashPaste, setFlashPaste] = createSignal(false);
// Manage highlight timeout to avoid memory leaks and race conditions on rapid pastes
let pasteHighlightTimeout: number | undefined;
const activeControllers = new Set<AbortController>();
const cancelledFiles = new Set<string>();
const manageController = (c: AbortController, action: 'add' | 'remove') => {
Expand Down Expand Up @@ -207,6 +211,59 @@ const AddFilePopup: Component<{collectionId: string, isMobile?: boolean}> = (pro
setModifying("new");
};
document.addEventListener("open-collection-upload", handler as EventListener);

// Paste-to-upload handler: allow Ctrl+V to paste files/images from clipboard with robust handling
const pasteHandler = (e: ClipboardEvent) => {
try {
// Avoid duplicate handling if a global handler already processed
if (e.defaultPrevented) return;

// Ignore when focused on inputs/textarea/contenteditable
const ae = document.activeElement as HTMLElement | null;
if (ae && (ae.tagName === 'INPUT' || ae.tagName === 'TEXTAREA' || ae.isContentEditable)) return;

const dt = e.clipboardData;
if (!dt) return;

const fromFiles = dt.files ? Array.from(dt.files) : [];
const fromItems = dt.items ? Array.from(dt.items)
.filter(it => it.kind === 'file')
.map(it => it.getAsFile())
.filter((f): f is File => !!f && f.size > 0) : [];
const seen = new Set<string>();
const files: File[] = [];
for (const f of [...fromFiles, ...fromItems]) {
const key = `${f.name}|${f.size}|${f.type}|${(f as any).lastModified ?? ''}`;
if (!seen.has(key)) { seen.add(key); files.push(f); }
}

if (files.length === 0) {
// Gracefully ignore text-only pastes outside inputs
return;
}

// Prevent default so browser doesn't insert pasted image blobs into DOM
e.preventDefault();

addDroppedFiles(files);
setOpen(true);
setModifying('new');

// UX feedback
try {
toast.success(`Pasted ${files.length} file${files.length > 1 ? 's' : ''}`);
} catch (toastErr) {
console.error('Paste toast failed:', toastErr);
}
setFlashPaste(true);
if (pasteHighlightTimeout) clearTimeout(pasteHighlightTimeout);
pasteHighlightTimeout = window.setTimeout(() => setFlashPaste(false), 1200);
} catch (err) {
console.error('Error handling paste in Collection AddFilePopup:', err);
}
};
document.addEventListener('paste', pasteHandler);

queueMicrotask(() => {
if (!open()) {
const pending = (window as any).__pendingCollectionUploadFiles as File[] | undefined;
Expand All @@ -218,7 +275,11 @@ const AddFilePopup: Component<{collectionId: string, isMobile?: boolean}> = (pro
}
}
});
onCleanup(() => document.removeEventListener("open-collection-upload", handler as EventListener));
onCleanup(() => {
document.removeEventListener("open-collection-upload", handler as EventListener);
document.removeEventListener('paste', pasteHandler);
if (pasteHighlightTimeout) clearTimeout(pasteHighlightTimeout);
});
});

const handleFileDelete = (uniqueIdToDelete: string) => {
Expand Down Expand Up @@ -310,7 +371,7 @@ const AddFilePopup: Component<{collectionId: string, isMobile?: boolean}> = (pro
<p class="text-white text-lg font-bold mb-2 text-center">Upload New File</p>
<label
for="collection-file-upload"
class={`rounded-md min-h-[15vh] flex justify-center items-center cursor-pointer my-[1vh] ${selectedUploadFiles().length === 0 ? `border-2 ${isDragOver() ? 'border-blue-400' : 'border-dotted border-blue-800'}` : ''}`}
class={`rounded-md min-h-[15vh] flex justify-center items-center cursor-pointer my-[1vh] ${selectedUploadFiles().length === 0 ? `border-2 ${isDragOver() ? 'border-blue-400' : 'border-dotted border-blue-800'}` : ''}${flashPaste() ? ' ring-2 ring-blue-500 animate-pulse' : ''}`}
onDragOver={(e) => { e.preventDefault(); setIsDragOver(true); }}
onDragEnter={(e) => { e.preventDefault(); setIsDragOver(true); }}
onDragLeave={() => setIsDragOver(false)}
Expand Down
19 changes: 12 additions & 7 deletions frontend/src/pages/MyDrive.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ const FilesError: Component = () => {
</div>
)}
{(status() === "error" || status() === "disconnected") && (
<div class={`${baseClass} border-1 bg-red-600/30 border-red-400`}>
<div class={`${baseClass} border bg-red-600/30 border-red-400`}>
<div class="pr-[0.75vw] text-red-600 w-16 md:w-[3vw]">
<ErrorSVG />
</div>
Expand Down Expand Up @@ -129,7 +129,7 @@ const FileUploadPreview: Component<{
<p class="text-gray-400 text-xs">{formatFileSize(file.size)}</p>
</div>
</div>
<div class="flex-grow ml-4 min-w-[100px]">
<div class="grow ml-4 min-w-[100px]">
<Show when={info() && (info()!.status === 'uploading' || info()!.status === 'pending')}>
<div class="w-full bg-gray-700 rounded-full h-2.5">
<div
Expand Down Expand Up @@ -326,6 +326,7 @@ async function uploadFileInChunks(


const UploadPopup: Component = () => {
const ctx = useContext(AppContext)!;
const [selectedFiles, setSelectedFiles] = createSignal<SelectableFile[]>([]);
const [uploadProgressMap, setUploadProgressMap] = createSignal<Record<string, FileUploadProgressData>>({});
const [isUploading, setIsUploading] = createSignal(false);
Expand Down Expand Up @@ -419,11 +420,15 @@ const UploadPopup: Component = () => {
// If navigation stored pending files, consume them (only if not already open)
queueMicrotask(() => {
if (!open()) {
const pending = (window as any).__pendingDriveUploadFiles as File[] | undefined;
if (pending && pending.length) {
addDroppedFiles(pending);
(window as any).__pendingDriveUploadFiles = undefined;
setOpen(true);
try {
const pending = ctx.pendingDriveUploadFiles?.() || null;
if (pending && pending.length) {
addDroppedFiles(pending);
ctx.setPendingDriveUploadFiles?.(null);
setOpen(true);
}
} catch (err) {
console.error('Error consuming pending drive upload files from context:', err);
}
}
});
Expand Down