diff --git a/README.md b/README.md index 4747877..882564d 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ structured chat data and Codex sessions in the browser. | :-------------------------- | :------------------------------------------------------------------------------------------------------------------------- | | Harmony conversation viewer | Renders Harmony conversations with support for different message types and metadata. | | Codex session viewer | Detects Codex session JSONL files, converts them into a conversation, and renders them in the same viewer. | -| Flexible loading | Loads data from the clipboard, local `.json` or `.jsonl` files, or public HTTP(S) JSON/JSONL URLs. | +| Flexible loading | Loads data from drag-and-drop, the clipboard, local `.json` or `.jsonl` files, or public HTTP(S) JSON/JSONL URLs. | | Markdown and HTML rendering | Renders markdown in message content, including formulas and optional HTML blocks. | | Translation | Translates non-English text into English in normal mode or frontend-only mode with a user-provided OpenAI API key. | | Metadata inspection | Exposes conversation-level and message-level metadata directly in the UI. | @@ -50,8 +50,9 @@ There are two ways you can use Euphony. 1. Load data from one of the supported sources: 1. Paste JSON or JSONL from the clipboard - 2. Choose a local `.json` or `.jsonl` file - 3. Provide a public HTTP(S) URL that serves JSON or JSONL (e.g., Hugging Face) + 2. Drag and drop a local `.json` or `.jsonl` file onto the app + 3. Choose a local `.json` or `.jsonl` file + 4. Provide a public HTTP(S) URL that serves JSON or JSONL (e.g., Hugging Face) 2. Euphony automatically detects and renders the input: 1. If the JSONL is a list of conversations → Euphony renders all conversations 2. If the JSONL is a Codex session file → Euphony renders a structured Codex session timeline diff --git a/src/components/app/app.css b/src/components/app/app.css index 27d817b..f0bae33 100644 --- a/src/components/app/app.css +++ b/src/components/app/app.css @@ -1,5 +1,11 @@ @import '../../css/component-global.css'; +.app-shell { + width: 100%; + height: 100%; + position: relative; +} + .app { width: 100%; height: 100%; @@ -54,6 +60,69 @@ } } +.local-file-drop-overlay { + position: absolute; + inset: 0; + z-index: 4; + + display: flex; + justify-content: center; + align-items: center; + + padding: 24px; + box-sizing: border-box; + + background: + linear-gradient( + 135deg, + color-mix(in lab, var(--blue-50), white 18%) 0%, + color-mix(in lab, var(--blue-100), white 30%) 100% + ), + rgba(255, 255, 255, 0.88); + backdrop-filter: blur(6px); + + opacity: 0; + pointer-events: none; + transition: opacity 160ms ease; + + &[is-visible] { + opacity: 1; + } +} + +.local-file-drop-panel { + width: min(520px, calc(100vw - 48px)); + padding: 28px 32px; + box-sizing: border-box; + + border: 2px dashed color-mix(in lab, var(--blue-300), black 8%); + border-radius: 18px; + background: rgba(255, 255, 255, 0.94); + box-shadow: + 0 20px 50px rgba(0, 90, 142, 0.12), + 0 6px 20px rgba(17, 27, 39, 0.08); + + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + text-align: center; +} + +.local-file-drop-title { + color: var(--blue-900); + font-size: 1.35rem; + font-weight: 700; + line-height: 1.2; +} + +.local-file-drop-subtitle { + color: var(--gray-700); + font-size: var(--font-d2); + line-height: 1.5; + max-width: 36ch; +} + .header { width: 100%; padding: 20px 0 20px 0; @@ -646,6 +715,8 @@ button, .toast-container { position: absolute; top: 7px; + left: 50%; + transform: translateX(-50%); z-index: 5; display: flex; justify-content: center; diff --git a/src/components/app/app.ts b/src/components/app/app.ts index 58fcc75..06de0cb 100644 --- a/src/components/app/app.ts +++ b/src/components/app/app.ts @@ -257,6 +257,9 @@ export class EuphonyApp extends LitElement { @state() isLoadingFromClipboard = false; + @state() + isDraggingLocalFile = false; + // Grid view mode @state() isGridView = false; @@ -294,6 +297,7 @@ export class EuphonyApp extends LitElement { // Debouncers cacheInfoTooltipDebouncer: number | null = null; + fileDragDepth = 0; //==========================================================================|| // Lifecycle Methods || @@ -928,6 +932,79 @@ export class EuphonyApp extends LitElement { } } + isFileDragEvent(e: DragEvent) { + return e.dataTransfer?.types.includes('Files') ?? false; + } + + resetFileDragState() { + this.fileDragDepth = 0; + this.isDraggingLocalFile = false; + } + + appDragEnter(e: DragEvent) { + if (!this.isFileDragEvent(e)) { + return; + } + + e.preventDefault(); + this.fileDragDepth += 1; + this.isDraggingLocalFile = true; + } + + appDragOver(e: DragEvent) { + if (!this.isFileDragEvent(e)) { + return; + } + + e.preventDefault(); + if (e.dataTransfer) { + e.dataTransfer.dropEffect = 'copy'; + } + this.isDraggingLocalFile = true; + } + + appDragLeave(e: DragEvent) { + if (!this.isFileDragEvent(e)) { + return; + } + + e.preventDefault(); + this.fileDragDepth = Math.max(0, this.fileDragDepth - 1); + if (this.fileDragDepth === 0) { + this.isDraggingLocalFile = false; + } + } + + appDrop(e: DragEvent) { + if (!this.isFileDragEvent(e)) { + return; + } + + e.preventDefault(); + const droppedFiles = Array.from(e.dataTransfer?.files ?? []); + this.resetFileDragState(); + + if (droppedFiles.length === 0) { + return; + } + + if (droppedFiles.length > 1) { + this.toastMessage = + 'Please drop exactly one local JSON or JSONL file at a time.'; + this.toastType = 'error'; + this.toastComponent?.show(); + return; + } + + this.isLoadingData = true; + this.loadDataFromFile(droppedFiles[0]).catch((error: unknown) => { + this.toastMessage = `Failed to read local file.\n\n${error}`; + this.toastType = 'error'; + this.toastComponent?.show(); + this.isLoadingData = false; + }); + } + preferenceWindowMaxMessageHeightChanged(e: CustomEvent) { const newHeight = e.detail; this.euphonyStyleConfig['--euphony-max-message-height'] = newHeight; @@ -1329,10 +1406,7 @@ export class EuphonyApp extends LitElement { } }; - loadDataFromText = ( - sourceText: string, - sourceName: 'clipboard' | 'file' - ) => { + loadDataFromText = (sourceText: string, sourceName: 'clipboard' | 'file') => { this.curPage = 1; this.resetHash(); const requestID = this.localDataWorkerRequestID; @@ -2233,10 +2307,37 @@ export class EuphonyApp extends LitElement { return html`
{ + this.appDragEnter(e); + }} + @dragover=${(e: DragEvent) => { + this.appDragOver(e); + }} + @dragleave=${(e: DragEvent) => { + this.appDragLeave(e); + }} + @drop=${(e: DragEvent) => { + this.appDrop(e); + }} > +
+
+
+ Drop a local JSON or JSONL file +
+
+ Euphony will load one file at a time using the existing local file + parser. +
+
+
+ ${tooltipTemplate} ${preferenceWindowTemplate}
-
- ${this.isEditorMode ? 'Euphony Editor' : 'Euphony'} - { - this.localFileInputChanged(e); - }} - /> - { - // Avoid triggering the page navigation when pressing arrow keys - e.stopPropagation(); - - const target = e.target as HTMLElement | null; - // Load the page when pressing enter - if (e.key === 'Enter') { - this.loadButtonClicked().then( - () => { - target?.blur(); - }, - () => {} - ); - } - }} - > - - - - - ${downloadButtonTemplate} - - + + ${downloadButtonTemplate} + + -
-
-
-
Loading data
-
-
+ // Check if the blur event is from the menu's button + if (relatedTarget?.tagName === 'NIGHTJAR-MENU') { + timeout = 200; + } -
0} - > - ☹️ No conversation loaded + setTimeout(() => { + this.showToolBarMenu = false; + }, timeout); + }} + > + ) => { + this.menuItemClicked(e); + }} + > +
+
-
+
+
+
Loading data
+
+
+
0} > - ${selectAllButtonTemplate} -
- ${this.isEditorMode - ? `${NUM_FORMATTER(this.selectedConversationIDs.size)} / ` - : ''} - ${NUM_FORMATTER(this.totalConversationSize)} - ${this.jmespathQuery !== '' ? 'matched' : 'total'} - ${this.dataType === DataType.JSON ? 'items' : 'conversations'} - ${this.jmespathQuery !== '' - ? `(${NUM_FORMATTER(this.totalConversationSizeIncludingUnfiltered)} total)` - : ''} -
- ${queryLabels} + ☹️ No conversation loaded
-
- ${conversationsTemplate} +
+
+ ${selectAllButtonTemplate} +
+ ${this.isEditorMode + ? `${NUM_FORMATTER(this.selectedConversationIDs.size)} / ` + : ''} + ${NUM_FORMATTER(this.totalConversationSize)} + ${this.jmespathQuery !== '' ? 'matched' : 'total'} + ${this.dataType === DataType.JSON ? 'items' : 'conversations'} + ${this.jmespathQuery !== '' + ? `(${NUM_FORMATTER(this.totalConversationSizeIncludingUnfiltered)} total)` + : ''} +
+ ${queryLabels} +
+ +
+ ${conversationsTemplate} +
+ +
- -
-
-
`;