mirror of
https://github.com/open-webui/open-webui.git
synced 2026-10-28 06:22:33 +00:00
refac
This commit is contained in:
parent
fbeee59e99
commit
a76c02e58e
6 changed files with 565 additions and 196 deletions
|
|
@ -17,8 +17,6 @@
|
|||
import { autoUpdate, computePosition, flip, offset, shift } from '@floating-ui/dom';
|
||||
import ArrowRight from '$lib/components/icons/ArrowRight.svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import Plus from '$lib/components/icons/Plus.svelte';
|
||||
import Folder from '$lib/components/icons/Folder.svelte';
|
||||
import { getContext, onMount, onDestroy, tick } from 'svelte';
|
||||
import type { Readable } from 'svelte/store';
|
||||
import type { i18n as I18n } from 'i18next';
|
||||
|
|
@ -344,6 +342,7 @@
|
|||
let sortBy: SortMode = 'name';
|
||||
let sortAsc = true;
|
||||
let showHidden = false;
|
||||
let viewMode: 'grid' | 'list' = 'list';
|
||||
let visibleEntries: BrowserRow[] = [];
|
||||
let expandedDirs: Set<string> = new Set();
|
||||
let treeCache: Map<string, FileEntry[]> = new Map();
|
||||
|
|
@ -462,9 +461,23 @@
|
|||
});
|
||||
|
||||
$: visibleEntries = withRowIndexes(
|
||||
buildVisibleRows(entries, currentPath, expandedDirs, treeCache, showHidden, sortBy, sortAsc)
|
||||
buildVisibleRows(
|
||||
entries,
|
||||
currentPath,
|
||||
viewMode === 'list' ? expandedDirs : new Set(),
|
||||
treeCache,
|
||||
showHidden,
|
||||
sortBy,
|
||||
sortAsc
|
||||
)
|
||||
);
|
||||
|
||||
const changeViewMode = (mode: 'grid' | 'list') => {
|
||||
clearSelection();
|
||||
viewMode = mode;
|
||||
localStorage.setItem('fileNav:viewMode', mode);
|
||||
};
|
||||
|
||||
// ── Navigation history ──────────────────────────────────────────────
|
||||
type NavEntry = { path: string; file: string | null };
|
||||
let navHistory: NavEntry[] = [];
|
||||
|
|
@ -1796,6 +1809,7 @@
|
|||
// ── Lifecycle ────────────────────────────────────────────────────────
|
||||
onMount(() => {
|
||||
showHidden = localStorage.getItem('fileNav:showHidden') === 'true';
|
||||
viewMode = localStorage.getItem('fileNav:viewMode') === 'grid' ? 'grid' : 'list';
|
||||
const terminal = getTerminal();
|
||||
|
||||
let handledDisplayFile = false;
|
||||
|
|
@ -2028,6 +2042,8 @@
|
|||
{sortBy}
|
||||
{sortAsc}
|
||||
{showHidden}
|
||||
viewMode={isSearching ? null : viewMode}
|
||||
onViewModeChange={changeViewMode}
|
||||
onGoBack={goBack}
|
||||
onGoForward={goForward}
|
||||
onNavigate={loadDir}
|
||||
|
|
@ -2527,18 +2543,25 @@
|
|||
{/if}
|
||||
|
||||
{#if visibleEntries.length > 0 || creatingFolder || creatingFile}
|
||||
<ul>
|
||||
<ul
|
||||
class={viewMode === 'grid'
|
||||
? 'grid grid-cols-[repeat(auto-fill,minmax(min(100px,calc((100%_-_0.625rem)/2)),1fr))] gap-2.5 p-3'
|
||||
: ''}
|
||||
>
|
||||
{#each visibleEntries as entry (entry.fullPath)}
|
||||
<FileEntryRow
|
||||
{entry}
|
||||
{viewMode}
|
||||
currentPath={entry.parentPath}
|
||||
fullPath={entry.fullPath}
|
||||
depth={entry.depth}
|
||||
rowIndex={entry.rowIndex}
|
||||
expanded={expandedDirs.has(entry.fullPath)}
|
||||
loadingChildren={loadingDirs.has(entry.fullPath)}
|
||||
folderContentsVersion={loadGeneration}
|
||||
terminalUrl={selectedTerminal.url}
|
||||
terminalKey={selectedTerminal.key}
|
||||
terminalSessionId={chatId ?? undefined}
|
||||
selected={selectedEntries.has(entry.fullPath)}
|
||||
{selectionMode}
|
||||
selectedPaths={selectedEntries}
|
||||
|
|
@ -2648,7 +2671,7 @@
|
|||
refreshBrowser();
|
||||
}}
|
||||
>
|
||||
<Icon name="refresh" size={12} strokeWidth={1.4} />
|
||||
<Icon name="refresh" size={14} strokeWidth={1.5} />
|
||||
<span>{$i18n.t('Refresh')}</span>
|
||||
</button>
|
||||
<button
|
||||
|
|
@ -2660,7 +2683,7 @@
|
|||
startNewFile();
|
||||
}}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
<Icon name="file-plus" size={14} strokeWidth={1.5} />
|
||||
<span>{$i18n.t('New File')}</span>
|
||||
</button>
|
||||
<button
|
||||
|
|
@ -2672,7 +2695,7 @@
|
|||
startNewFolder();
|
||||
}}
|
||||
>
|
||||
<Folder className="size-3.5" />
|
||||
<Icon name="folder-plus" size={14} strokeWidth={1.5} />
|
||||
<span>{$i18n.t('New Folder')}</span>
|
||||
</button>
|
||||
<button
|
||||
|
|
@ -2684,7 +2707,7 @@
|
|||
openUploadPicker();
|
||||
}}
|
||||
>
|
||||
<Icon name="upload" size={12} strokeWidth={1.4} />
|
||||
<Icon name="upload-tray" size={14} strokeWidth={1.5} />
|
||||
<span>{$i18n.t('Upload')}</span>
|
||||
</button>
|
||||
<button
|
||||
|
|
@ -2696,7 +2719,7 @@
|
|||
openUploadPicker(true);
|
||||
}}
|
||||
>
|
||||
<Icon name="upload" size={12} strokeWidth={1.4} />
|
||||
<Icon name="folder-upload" size={14} strokeWidth={1.5} />
|
||||
<span>{$i18n.t('Upload Folder')}</span>
|
||||
</button>
|
||||
<button
|
||||
|
|
@ -2707,7 +2730,7 @@
|
|||
downloadFile(currentPath);
|
||||
}}
|
||||
>
|
||||
<Icon name="download" size={12} strokeWidth={1.4} />
|
||||
<Icon name="download-tray" size={14} strokeWidth={1.5} />
|
||||
<span>{$i18n.t('Download')}</span>
|
||||
</button>
|
||||
<button
|
||||
|
|
@ -2718,7 +2741,7 @@
|
|||
toggleHidden();
|
||||
}}
|
||||
>
|
||||
<Icon name="eye" size={12} strokeWidth={1.4} />
|
||||
<Icon name={showHidden ? 'eye-off' : 'eye'} size={14} strokeWidth={1.5} />
|
||||
<span>{showHidden ? $i18n.t('Hide Hidden Files') : $i18n.t('Show Hidden Files')}</span>
|
||||
</button>
|
||||
</DropdownMenu>
|
||||
|
|
|
|||
|
|
@ -1,16 +1,24 @@
|
|||
<script context="module" lang="ts">
|
||||
let closeActiveHoverPreview: (() => void) | null = null;
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { LinkPreview } from 'bits-ui';
|
||||
import { mobile } from '$lib/stores';
|
||||
import { getContext, tick, onDestroy } from 'svelte';
|
||||
import { copyToClipboard, formatFileSize } from '$lib/utils';
|
||||
import type { FileEntry } from '$lib/apis/terminal';
|
||||
import { listFiles, type FileEntry } from '$lib/apis/terminal';
|
||||
|
||||
import Dropdown from '$lib/components/common/Dropdown.svelte';
|
||||
import DropdownMenu from '$lib/components/common/DropdownMenu.svelte';
|
||||
import FileTypeIcon from './FileTypeIcon.svelte';
|
||||
import Icon from './Icon.svelte';
|
||||
import FolderOpen from '$lib/components/icons/FolderOpen.svelte';
|
||||
|
||||
export let dragType = 'application/x-terminal-file-move';
|
||||
export let variant: 'terminal' | 'workspace' = 'terminal';
|
||||
export let viewMode: 'grid' | 'list' = 'list';
|
||||
const i18n: any = getContext('i18n');
|
||||
|
||||
export let entry: FileEntry;
|
||||
|
|
@ -20,8 +28,10 @@
|
|||
export let rowIndex = 0;
|
||||
export let expanded = false;
|
||||
export let loadingChildren = false;
|
||||
export let folderContentsVersion = 0;
|
||||
export let terminalUrl: string = '';
|
||||
export let terminalKey: string = '';
|
||||
export let terminalSessionId: string | undefined = undefined;
|
||||
|
||||
export let onOpen: (entry: FileEntry) => void = () => {};
|
||||
export let onNewFolder: ((path: string) => void) | undefined = undefined;
|
||||
|
|
@ -53,6 +63,7 @@
|
|||
$: entryPath =
|
||||
fullPath ??
|
||||
(entry.type === 'directory' ? `${currentPath}${entry.name}/` : `${currentPath}${entry.name}`);
|
||||
$: folderCard = viewMode === 'grid' && entry.type === 'directory';
|
||||
$: directoryPath = entryPath.endsWith('/') ? entryPath : `${entryPath}/`;
|
||||
$: writable = entry.writable !== false;
|
||||
$: canMutate = parentWritable && writable;
|
||||
|
|
@ -75,6 +86,22 @@
|
|||
let dragOverFolder = false;
|
||||
let expandTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let menuOpen = false;
|
||||
let openPreview = false;
|
||||
let dragging = false;
|
||||
const closeHoverPreview = () => (openPreview = false);
|
||||
$: hoverDisabled =
|
||||
entry.type !== 'file' ||
|
||||
!terminalUrl ||
|
||||
$mobile ||
|
||||
renaming ||
|
||||
menuOpen ||
|
||||
dragging ||
|
||||
selectionMode;
|
||||
$: if (hoverDisabled) openPreview = false;
|
||||
$: if (openPreview && closeActiveHoverPreview !== closeHoverPreview) {
|
||||
closeActiveHoverPreview?.();
|
||||
closeActiveHoverPreview = closeHoverPreview;
|
||||
}
|
||||
|
||||
const clearExpandTimer = () => {
|
||||
if (!expandTimer) return;
|
||||
|
|
@ -82,6 +109,32 @@
|
|||
expandTimer = null;
|
||||
};
|
||||
|
||||
let folderHasContents: boolean | null = null;
|
||||
const observeFolderContents = (node: Element) => {
|
||||
folderHasContents = null;
|
||||
if (!terminalUrl) return;
|
||||
let disposed = false;
|
||||
const url = terminalUrl;
|
||||
const key = terminalKey;
|
||||
const session = terminalSessionId;
|
||||
const path = directoryPath;
|
||||
const observer = new IntersectionObserver((observations) => {
|
||||
if (!observations.some((observation) => observation.isIntersecting)) return;
|
||||
observer.disconnect();
|
||||
// Only inspect visible folders; failed reads leave their contents unknown.
|
||||
void listFiles(url, key, path, session).then((result) => {
|
||||
if (!disposed && result) folderHasContents = result.entries.length > 0;
|
||||
});
|
||||
});
|
||||
observer.observe(node);
|
||||
return {
|
||||
destroy() {
|
||||
disposed = true;
|
||||
observer.disconnect();
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// ── Rename state ─────────────────────────────────────────────────────
|
||||
let renaming = false;
|
||||
let renameValue = '';
|
||||
|
|
@ -142,12 +195,14 @@
|
|||
};
|
||||
|
||||
onDestroy(() => {
|
||||
if (closeActiveHoverPreview === closeHoverPreview) closeActiveHoverPreview = null;
|
||||
if (longPressTimer) clearTimeout(longPressTimer);
|
||||
clearExpandTimer();
|
||||
});
|
||||
|
||||
// ── Click handler ────────────────────────────────────────────────────
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
openPreview = false;
|
||||
if (renaming) return;
|
||||
if (didLongPress) {
|
||||
didLongPress = false;
|
||||
|
|
@ -172,13 +227,19 @@
|
|||
};
|
||||
</script>
|
||||
|
||||
<li class="group" data-file-row>
|
||||
<li class="group min-w-0 {viewMode === 'grid' ? 'self-start' : ''}" data-file-row>
|
||||
<div
|
||||
class="w-full flex items-center transition-colors duration-75 {variant === 'workspace'
|
||||
? 'rounded-xl'
|
||||
: ''}
|
||||
{selected || active ? 'bg-gray-100 dark:bg-white/8' : 'hover:bg-gray-50/40 dark:hover:bg-white/4'}
|
||||
{dragOverFolder
|
||||
class="w-full flex items-center transition-colors duration-75 {viewMode === 'grid'
|
||||
? folderCard
|
||||
? 'relative h-18'
|
||||
: 'relative h-[4.125rem] rounded-md border border-black/[0.06] bg-gray-50/40 dark:border-white/[0.06] dark:bg-gray-900'
|
||||
: ''} {variant === 'workspace' ? 'rounded-xl' : ''}
|
||||
{folderCard
|
||||
? ''
|
||||
: selected || active
|
||||
? 'bg-gray-100 dark:bg-white/8'
|
||||
: 'hover:bg-gray-50/40 dark:hover:bg-white/4'}
|
||||
{dragOverFolder && !folderCard
|
||||
? 'bg-gray-100 dark:bg-white/8 ring-1 ring-black/15 dark:ring-white/15 ring-inset'
|
||||
: ''}"
|
||||
role="presentation"
|
||||
|
|
@ -189,7 +250,7 @@
|
|||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragOverFolder = true;
|
||||
if (!expanded && !expandTimer) {
|
||||
if (viewMode === 'list' && !expanded && !expandTimer) {
|
||||
expandTimer = setTimeout(() => {
|
||||
onToggleExpand(directoryPath);
|
||||
expandTimer = null;
|
||||
|
|
@ -222,9 +283,45 @@
|
|||
} catch {}
|
||||
}}
|
||||
>
|
||||
{#if variant === 'workspace'}
|
||||
{#if folderCard}
|
||||
{#key JSON.stringify( [terminalUrl, terminalKey, terminalSessionId, directoryPath, entry.modified, folderContentsVersion] )}
|
||||
<svg
|
||||
use:observeFolderContents
|
||||
class="pointer-events-none absolute inset-0 h-full w-full {dragOverFolder
|
||||
? 'text-black/25 dark:text-white/25'
|
||||
: 'text-black/10 dark:text-white/10'}"
|
||||
viewBox="0 0 200 72"
|
||||
preserveAspectRatio="none"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<!-- The back, sloped tab, and front are one folder silhouette. -->
|
||||
<path
|
||||
d="M1 19V8Q1 1 9 1H54Q58 1 61 4L65 7Q67 9 71 9H191Q199 9 199 15V19"
|
||||
vector-effect="non-scaling-stroke"
|
||||
/>
|
||||
{#if folderHasContents}
|
||||
<path
|
||||
d="M7 13V12Q7 9 12 9H188Q193 9 193 12V13Z"
|
||||
vector-effect="non-scaling-stroke"
|
||||
class="fill-gray-50 text-black/10 dark:fill-[color-mix(in_srgb,var(--color-gray-900),white_4%)] dark:text-white/10"
|
||||
/>
|
||||
{/if}
|
||||
<path
|
||||
d="M1 20Q1 13 9 13H191Q199 13 199 20V64Q199 71 191 71H9Q1 71 1 64Z"
|
||||
vector-effect="non-scaling-stroke"
|
||||
class={selected || active || dragOverFolder
|
||||
? 'fill-black/5 dark:fill-white/8'
|
||||
: 'fill-transparent group-hover:fill-black/[0.02] dark:group-hover:fill-white/4'}
|
||||
/>
|
||||
</svg>
|
||||
{/key}
|
||||
{/if}
|
||||
{#if viewMode === 'list' && variant === 'workspace'}
|
||||
<span class="shrink-0" style:width={rowIndent}></span>
|
||||
{:else if entry.type === 'directory'}
|
||||
{:else if viewMode === 'list' && entry.type === 'directory'}
|
||||
<button
|
||||
type="button"
|
||||
class="mr-1.5 flex w-3 shrink-0 items-center self-stretch justify-center text-gray-400 dark:text-gray-600 hover:text-gray-600 dark:hover:text-gray-400"
|
||||
|
|
@ -239,7 +336,7 @@
|
|||
class={loadingChildren ? 'animate-pulse' : ''}
|
||||
/>
|
||||
</button>
|
||||
{:else}
|
||||
{:else if viewMode === 'list'}
|
||||
<span class="mr-1.5 w-3 shrink-0 self-stretch" style="margin-left: {rowIndent};"></span>
|
||||
{/if}
|
||||
|
||||
|
|
@ -251,120 +348,179 @@
|
|||
aria-label={$i18n.t('Select {{name}}', { name: entry.name })}
|
||||
class="mr-2 flex size-3.5 shrink-0 items-center justify-center rounded border transition-colors {selected
|
||||
? 'bg-gray-900 border-gray-900 text-white dark:bg-gray-200 dark:border-gray-200 dark:text-gray-900'
|
||||
: 'border-gray-300 dark:border-gray-600'}"
|
||||
: 'border-gray-300 dark:border-gray-600'} {viewMode === 'grid'
|
||||
? 'absolute bottom-2 left-2 z-10'
|
||||
: ''}"
|
||||
on:click|stopPropagation={(event) => onSelect(entry, event, entryPath, rowIndex)}
|
||||
>
|
||||
{#if selected}<Icon name="check" size={10} strokeWidth={2} />{/if}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="flex min-w-0 flex-1 items-center gap-1.5 py-1.5 pr-2 text-left"
|
||||
draggable={canMutate}
|
||||
on:dragstart={(e) => {
|
||||
if (!canMutate) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
const filePath = entryPath.replace(/\/$/, '');
|
||||
// If dragging a selected item, drag all selected
|
||||
if (selected && selectedPaths.size > 1) {
|
||||
e.dataTransfer?.setData(dragType, JSON.stringify({ paths: [...selectedPaths] }));
|
||||
// Custom drag ghost showing count
|
||||
const ghost = document.createElement('div');
|
||||
ghost.style.cssText =
|
||||
'position:fixed;top:-1000px;left:-1000px;display:flex;align-items:center;gap:0.375rem;padding:0.25rem 0.625rem;border-radius:0.5rem;background:#374151;color:#fff;font-size:0.75rem;white-space:nowrap;pointer-events:none;';
|
||||
ghost.textContent = `${selectedPaths.size} items`;
|
||||
document.body.appendChild(ghost);
|
||||
e.dataTransfer?.setDragImage(ghost, 0, 0);
|
||||
requestAnimationFrame(() => ghost.remove());
|
||||
} else {
|
||||
e.dataTransfer?.setData(
|
||||
dragType,
|
||||
JSON.stringify({
|
||||
path: entry.type === 'directory' ? directoryPath : filePath,
|
||||
name: entry.name
|
||||
})
|
||||
);
|
||||
}
|
||||
if (entry.type === 'file' && terminalUrl) {
|
||||
e.dataTransfer?.setData(
|
||||
'application/x-terminal-file',
|
||||
JSON.stringify({
|
||||
path: filePath,
|
||||
name: entry.name,
|
||||
url: terminalUrl,
|
||||
key: terminalKey
|
||||
})
|
||||
);
|
||||
}
|
||||
}}
|
||||
on:pointerdown={onPointerDown}
|
||||
on:pointerup={onPointerUp}
|
||||
on:pointercancel={onPointerCancel}
|
||||
aria-expanded={variant === 'workspace' && entry.type === 'directory' ? expanded : undefined}
|
||||
on:click={handleClick}
|
||||
on:dblclick|preventDefault|stopPropagation={() => {
|
||||
startRename();
|
||||
}}
|
||||
<LinkPreview.Root
|
||||
openDelay={300}
|
||||
closeDelay={0}
|
||||
disabled={hoverDisabled}
|
||||
bind:open={openPreview}
|
||||
>
|
||||
{#if variant === 'workspace'}
|
||||
{#if entry.type === 'directory'}
|
||||
<Icon name={expanded ? 'chevron-down' : 'chevron-right'} size={12} strokeWidth={1.5} />
|
||||
{:else}<span class="w-3 shrink-0"></span>{/if}
|
||||
{/if}
|
||||
<FileTypeIcon name={entry.name} type={entry.type} size={14} />
|
||||
{#if renaming}
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<input
|
||||
bind:this={renameInput}
|
||||
bind:value={renameValue}
|
||||
class="flex-1 text-xs bg-transparent outline-hidden text-gray-800 dark:text-gray-200 min-w-0 {variant ===
|
||||
'workspace'
|
||||
? 'h-4 p-0 leading-4'
|
||||
: 'py-0.5'}"
|
||||
on:keydown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
submitRename();
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
cancelRename();
|
||||
}
|
||||
}}
|
||||
on:blur={submitRename}
|
||||
on:click|stopPropagation
|
||||
/>
|
||||
{:else}
|
||||
<span class="flex-1 text-xs text-gray-800 dark:text-gray-200 truncate">
|
||||
{entry.name}
|
||||
</span>
|
||||
{/if}
|
||||
{#if !writable && !renaming && variant !== 'workspace'}
|
||||
<span class="text-[0.625rem] text-gray-400 shrink-0">{$i18n.t('Read-only')}</span>
|
||||
{/if}
|
||||
{#if entry.type === 'file' && entry.size !== undefined && !renaming}
|
||||
{#if showDate && entry.modified}
|
||||
<span class="text-[0.625rem] text-gray-400 shrink-0"
|
||||
>{formatRelativeTime(entry.modified)}</span
|
||||
<LinkPreview.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<button
|
||||
{...props}
|
||||
type="button"
|
||||
class="flex min-w-0 flex-1 items-center {viewMode === 'grid'
|
||||
? folderCard
|
||||
? 'h-18 flex-col !items-start justify-start gap-0.5 px-1.5 pt-4 pb-1.5 text-left'
|
||||
: 'h-16 flex-col !items-start justify-start gap-0.5 p-1.5 text-left'
|
||||
: 'gap-1.5 py-1.5 pr-2 text-left'}"
|
||||
title={hoverDisabled ? entry.name : undefined}
|
||||
draggable={canMutate}
|
||||
on:dragend={() => (dragging = false)}
|
||||
on:dragstart={(e) => {
|
||||
openPreview = false;
|
||||
if (!canMutate) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
dragging = true;
|
||||
const filePath = entryPath.replace(/\/$/, '');
|
||||
// If dragging a selected item, drag all selected
|
||||
if (selected && selectedPaths.size > 1) {
|
||||
e.dataTransfer?.setData(dragType, JSON.stringify({ paths: [...selectedPaths] }));
|
||||
// Custom drag ghost showing count
|
||||
const ghost = document.createElement('div');
|
||||
ghost.style.cssText =
|
||||
'position:fixed;top:-1000px;left:-1000px;display:flex;align-items:center;gap:0.375rem;padding:0.25rem 0.625rem;border-radius:0.5rem;background:#374151;color:#fff;font-size:0.75rem;white-space:nowrap;pointer-events:none;';
|
||||
ghost.textContent = `${selectedPaths.size} items`;
|
||||
document.body.appendChild(ghost);
|
||||
e.dataTransfer?.setDragImage(ghost, 0, 0);
|
||||
requestAnimationFrame(() => ghost.remove());
|
||||
} else {
|
||||
e.dataTransfer?.setData(
|
||||
dragType,
|
||||
JSON.stringify({
|
||||
path: entry.type === 'directory' ? directoryPath : filePath,
|
||||
name: entry.name
|
||||
})
|
||||
);
|
||||
}
|
||||
if (entry.type === 'file' && terminalUrl) {
|
||||
e.dataTransfer?.setData(
|
||||
'application/x-terminal-file',
|
||||
JSON.stringify({
|
||||
path: filePath,
|
||||
name: entry.name,
|
||||
url: terminalUrl,
|
||||
key: terminalKey
|
||||
})
|
||||
);
|
||||
}
|
||||
}}
|
||||
on:pointerdown={onPointerDown}
|
||||
on:pointerup={onPointerUp}
|
||||
on:pointercancel={onPointerCancel}
|
||||
aria-expanded={variant === 'workspace' && entry.type === 'directory'
|
||||
? expanded
|
||||
: props['aria-expanded']}
|
||||
on:click={handleClick}
|
||||
on:dblclick|preventDefault|stopPropagation={() => {
|
||||
startRename();
|
||||
}}
|
||||
>
|
||||
{/if}
|
||||
<span class="text-xs text-gray-400 shrink-0">{formatFileSize(entry.size)}</span>
|
||||
{:else if entry.type === 'directory' && showDate && entry.modified && !renaming}
|
||||
<span class="text-[0.625rem] text-gray-400 shrink-0"
|
||||
>{formatRelativeTime(entry.modified)}</span
|
||||
>
|
||||
{#if variant === 'workspace'}
|
||||
{#if entry.type === 'directory'}
|
||||
<Icon
|
||||
name={expanded ? 'chevron-down' : 'chevron-right'}
|
||||
size={12}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
{:else}<span class="w-3 shrink-0"></span>{/if}
|
||||
{/if}
|
||||
{#if viewMode === 'list'}
|
||||
<FileTypeIcon name={entry.name} type={entry.type} size={14} />
|
||||
{/if}
|
||||
{#if renaming}
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<input
|
||||
bind:this={renameInput}
|
||||
bind:value={renameValue}
|
||||
class="flex-1 text-xs bg-transparent outline-hidden text-gray-800 dark:text-gray-200 min-w-0 {variant ===
|
||||
'workspace'
|
||||
? 'h-4 p-0 leading-4'
|
||||
: 'py-0.5'} {viewMode === 'grid' ? 'w-full pr-6' : ''}"
|
||||
on:keydown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
submitRename();
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
cancelRename();
|
||||
}
|
||||
}}
|
||||
on:blur={submitRename}
|
||||
on:click|stopPropagation
|
||||
/>
|
||||
{:else}
|
||||
<span
|
||||
class="text-xs text-gray-800 dark:text-gray-200 {viewMode === 'grid'
|
||||
? 'w-full shrink-0 line-clamp-2 break-words pr-5'
|
||||
: 'flex-1 truncate'}"
|
||||
>
|
||||
{entry.name}
|
||||
</span>
|
||||
{/if}
|
||||
{#if !writable && !renaming && variant !== 'workspace'}
|
||||
<span class="text-[0.625rem] text-gray-400 shrink-0">{$i18n.t('Read-only')}</span>
|
||||
{/if}
|
||||
{#if entry.type === 'file' && viewMode === 'grid' && !renaming}
|
||||
<span
|
||||
class="mt-auto shrink-0 max-w-full truncate rounded bg-black/5 px-1 text-[0.5625rem] leading-3 uppercase text-gray-500 dark:bg-white/5 dark:text-gray-400"
|
||||
>
|
||||
{entry.name.includes('.')
|
||||
? entry.name.split('.').pop() || $i18n.t('File')
|
||||
: $i18n.t('File')}
|
||||
</span>
|
||||
{:else if entry.type === 'file' && entry.size !== undefined && !renaming}
|
||||
{#if showDate && entry.modified}
|
||||
<span class="text-[0.625rem] text-gray-400 shrink-0"
|
||||
>{formatRelativeTime(entry.modified)}</span
|
||||
>
|
||||
{/if}
|
||||
<span class="text-xs text-gray-400 shrink-0">{formatFileSize(entry.size)}</span>
|
||||
{:else if entry.type === 'directory' && showDate && entry.modified && !renaming}
|
||||
<span class="text-[0.625rem] text-gray-400 shrink-0"
|
||||
>{formatRelativeTime(entry.modified)}</span
|
||||
>
|
||||
{/if}
|
||||
</button>
|
||||
{/snippet}
|
||||
</LinkPreview.Trigger>
|
||||
{#if openPreview}
|
||||
{#key JSON.stringify( [terminalUrl, terminalKey, terminalSessionId, entryPath, entry.modified, entry.size] )}
|
||||
{#await import('./FileHoverPreview.svelte') then { default: FileHoverPreview }}
|
||||
<FileHoverPreview
|
||||
{entry}
|
||||
path={entryPath}
|
||||
{terminalUrl}
|
||||
{terminalKey}
|
||||
sessionId={terminalSessionId}
|
||||
/>
|
||||
{/await}
|
||||
{/key}
|
||||
{/if}
|
||||
</button>
|
||||
</LinkPreview.Root>
|
||||
|
||||
<Dropdown bind:show={menuOpen} align="end" sideOffset={4}>
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 flex h-5 w-5 items-center justify-center mr-1 rounded transition
|
||||
text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-400
|
||||
hover:bg-gray-50/40 dark:hover:bg-white/4"
|
||||
hover:bg-gray-50/40 dark:hover:bg-white/4 {viewMode === 'grid'
|
||||
? folderCard
|
||||
? 'absolute right-0 top-4'
|
||||
: 'absolute right-0 top-1'
|
||||
: ''}"
|
||||
aria-label={$i18n.t('More')}
|
||||
>
|
||||
<Icon name="three-dots" size={12} strokeWidth={1.4} />
|
||||
|
|
@ -372,26 +528,6 @@
|
|||
|
||||
<div slot="content">
|
||||
<DropdownMenu className="min-w-[9.375rem] z-[9999999]">
|
||||
{#if variant !== 'workspace' || entry.type !== 'directory'}
|
||||
<button
|
||||
type="button"
|
||||
class={menuItemClass}
|
||||
on:click={(e) => {
|
||||
e.stopPropagation();
|
||||
menuOpen = false;
|
||||
onOpen(entry);
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
name={entry.type === 'directory' ? 'folder' : 'eye'}
|
||||
size={12}
|
||||
strokeWidth={1.4}
|
||||
/>
|
||||
<div class="flex items-center">
|
||||
{entry.type === 'directory' ? $i18n.t('Open Folder') : $i18n.t('Open')}
|
||||
</div>
|
||||
</button>
|
||||
{/if}
|
||||
{#if entry.type === 'directory' && onNewFolder}
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -403,7 +539,7 @@
|
|||
onNewFolder?.(directoryPath);
|
||||
}}
|
||||
>
|
||||
<Icon name="folder" size={12} strokeWidth={1.4} />
|
||||
<Icon name="folder-plus" size={14} strokeWidth={1.5} />
|
||||
<div class="flex items-center">{$i18n.t('New Folder')}</div>
|
||||
</button>
|
||||
{/if}
|
||||
|
|
@ -418,10 +554,33 @@
|
|||
onNewFile?.(directoryPath);
|
||||
}}
|
||||
>
|
||||
<Icon name="plus" size={12} strokeWidth={1.4} />
|
||||
<Icon name="file-plus" size={14} strokeWidth={1.5} />
|
||||
<div class="flex items-center">{$i18n.t('New File')}</div>
|
||||
</button>
|
||||
{/if}
|
||||
{#if entry.type === 'directory' && (onNewFolder || onNewFile)}
|
||||
<div class="my-1 border-t border-gray-100 dark:border-white/10" role="separator"></div>
|
||||
{/if}
|
||||
{#if variant !== 'workspace' || entry.type !== 'directory'}
|
||||
<button
|
||||
type="button"
|
||||
class={menuItemClass}
|
||||
on:click={(e) => {
|
||||
e.stopPropagation();
|
||||
menuOpen = false;
|
||||
onOpen(entry);
|
||||
}}
|
||||
>
|
||||
{#if entry.type === 'directory'}
|
||||
<FolderOpen className="size-3.5 shrink-0" />
|
||||
{:else}
|
||||
<Icon name="eye" size={14} strokeWidth={1.5} />
|
||||
{/if}
|
||||
<div class="flex items-center">
|
||||
{entry.type === 'directory' ? $i18n.t('Open Folder') : $i18n.t('Open')}
|
||||
</div>
|
||||
</button>
|
||||
{/if}
|
||||
{#if entry.type === 'file' && onSelectForComparison}
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -431,7 +590,9 @@
|
|||
onSelectForComparison?.();
|
||||
}}
|
||||
>
|
||||
<Icon name="page" size={12} /><span>{$i18n.t('Select for Compare')}</span>
|
||||
<Icon name="page" size={14} strokeWidth={1.5} /><span
|
||||
>{$i18n.t('Select for Compare')}</span
|
||||
>
|
||||
</button>
|
||||
{#if onCompareWithSelected}
|
||||
<button
|
||||
|
|
@ -443,31 +604,33 @@
|
|||
onCompareWithSelected?.();
|
||||
}}
|
||||
>
|
||||
<Icon name="split-horizontal" size={12} /><span
|
||||
<Icon name="split-horizontal" size={14} strokeWidth={1.5} /><span
|
||||
>{$i18n.t('Compare with Selected')}</span
|
||||
>
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if entry.type === 'directory'}
|
||||
<button
|
||||
type="button"
|
||||
class={menuItemClass}
|
||||
on:click={(e) => {
|
||||
e.stopPropagation();
|
||||
menuOpen = false;
|
||||
onToggleExpand(directoryPath);
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
name={expanded ? 'chevron-down' : 'chevron-right'}
|
||||
size={12}
|
||||
strokeWidth={1.4}
|
||||
/>
|
||||
<div class="flex items-center">
|
||||
{expanded ? $i18n.t('Collapse') : $i18n.t('Expand')}
|
||||
</div>
|
||||
</button>
|
||||
{#if viewMode === 'list'}
|
||||
<button
|
||||
type="button"
|
||||
class={menuItemClass}
|
||||
on:click={(e) => {
|
||||
e.stopPropagation();
|
||||
menuOpen = false;
|
||||
onToggleExpand(directoryPath);
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
name={expanded ? 'chevron-down' : 'chevron-right'}
|
||||
size={14}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
<div class="flex items-center">
|
||||
{expanded ? $i18n.t('Collapse') : $i18n.t('Expand')}
|
||||
</div>
|
||||
</button>
|
||||
{/if}
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -478,7 +641,7 @@
|
|||
onDownload(entryPath);
|
||||
}}
|
||||
>
|
||||
<Icon name="download" size={12} strokeWidth={1.4} />
|
||||
<Icon name="download-tray" size={14} strokeWidth={1.5} />
|
||||
<div class="flex items-center">{$i18n.t('Download')}</div>
|
||||
</button>
|
||||
{/if}
|
||||
|
|
@ -494,7 +657,7 @@
|
|||
}
|
||||
}}
|
||||
>
|
||||
<Icon name="copy" size={12} strokeWidth={1.4} />
|
||||
<Icon name="copy" size={14} strokeWidth={1.5} />
|
||||
<div class="flex items-center">{$i18n.t('Copy Path')}</div>
|
||||
</button>
|
||||
|
||||
|
|
@ -509,7 +672,7 @@
|
|||
startRename();
|
||||
}}
|
||||
>
|
||||
<Icon name="pencil" size={12} strokeWidth={1.4} />
|
||||
<Icon name="pencil" size={14} strokeWidth={1.5} />
|
||||
<div class="flex items-center">{$i18n.t('Rename')}</div>
|
||||
</button>
|
||||
|
||||
|
|
@ -524,7 +687,7 @@
|
|||
onDelete(entryPath.replace(/\/$/, ''), entry.name);
|
||||
}}
|
||||
>
|
||||
<Icon name="trash" size={12} strokeWidth={1.4} />
|
||||
<Icon name="trash" size={14} strokeWidth={1.5} />
|
||||
<div class="flex items-center">{$i18n.t('Delete')}</div>
|
||||
</button>
|
||||
</DropdownMenu>
|
||||
|
|
|
|||
134
src/lib/components/chat/FileNav/FileHoverPreview.svelte
Normal file
134
src/lib/components/chat/FileNav/FileHoverPreview.svelte
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
<script lang="ts">
|
||||
import { getContext, onMount, onDestroy } from 'svelte';
|
||||
import { LinkPreview } from 'bits-ui';
|
||||
import { readFile, downloadFileBlob, type FileEntry } from '$lib/apis/terminal';
|
||||
import { formatFileSize } from '$lib/utils';
|
||||
import { supportsComments } from '$lib/utils/comments/types';
|
||||
import FilePreview from './FilePreview.svelte';
|
||||
import CommentFilePreview from './CommentFilePreview.svelte';
|
||||
import FileTypeIcon from './FileTypeIcon.svelte';
|
||||
|
||||
const i18n: any = getContext('i18n');
|
||||
export let entry: FileEntry;
|
||||
export let path: string;
|
||||
export let terminalUrl: string;
|
||||
export let terminalKey: string;
|
||||
export let sessionId: string | undefined = undefined;
|
||||
|
||||
let loading = true;
|
||||
let fileContent: string | null = null;
|
||||
let fileImageUrl: string | null = null;
|
||||
let fileVideoUrl: string | null = null;
|
||||
let fileAudioUrl: string | null = null;
|
||||
let fileSqliteData: ArrayBuffer | null = null;
|
||||
let disposed = false;
|
||||
const controller = new AbortController();
|
||||
const documentPreview = supportsComments(path);
|
||||
// shortcut: Avoid large automatic downloads until bounded preview reads are available.
|
||||
const withinPreviewLimit = entry.size !== undefined && entry.size <= 20 * 1024 * 1024;
|
||||
|
||||
onMount(async () => {
|
||||
if (documentPreview || !withinPreviewLimit) {
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
const extension = path.split('.').pop()?.toLowerCase() ?? '';
|
||||
const isImage = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'ico', 'avif', 'webp'].includes(extension);
|
||||
const isVideo = ['mp4', 'webm', 'mov', 'ogv', 'avi', 'mkv'].includes(extension);
|
||||
const isAudio = ['mp3', 'wav', 'ogg', 'oga', 'flac', 'm4a', 'aac', 'wma', 'opus'].includes(
|
||||
extension
|
||||
);
|
||||
const isSqlite = ['db', 'sqlite', 'sqlite3', 'db3'].includes(extension);
|
||||
try {
|
||||
if (isImage || isVideo || isAudio || isSqlite) {
|
||||
const result = await downloadFileBlob(
|
||||
terminalUrl,
|
||||
terminalKey,
|
||||
path,
|
||||
sessionId,
|
||||
controller.signal
|
||||
);
|
||||
if (!result || disposed) return;
|
||||
if (isSqlite) {
|
||||
const bytes = await result.blob.arrayBuffer();
|
||||
if (!disposed) fileSqliteData = bytes;
|
||||
} else {
|
||||
const url = URL.createObjectURL(result.blob);
|
||||
if (isImage) fileImageUrl = url;
|
||||
else if (isVideo) fileVideoUrl = url;
|
||||
else fileAudioUrl = url;
|
||||
}
|
||||
} else if (entry.size! <= 1024 * 1024) {
|
||||
const text = await readFile(terminalUrl, terminalKey, path, sessionId);
|
||||
if (!disposed && text !== null && !text.startsWith('[Binary file:')) fileContent = text;
|
||||
}
|
||||
} catch {
|
||||
// A failed preview must not prevent opening the file normally.
|
||||
} finally {
|
||||
if (!disposed) loading = false;
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
disposed = true;
|
||||
controller.abort();
|
||||
for (const url of [fileImageUrl, fileVideoUrl, fileAudioUrl]) {
|
||||
if (url) URL.revokeObjectURL(url);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<LinkPreview.Portal>
|
||||
<LinkPreview.Content
|
||||
class="z-[9999] hidden w-[20rem] max-w-[calc(100vw-1.5rem)] overflow-hidden rounded-2xl bg-white shadow-[0_16px_40px_-28px_rgba(0,0,0,0.55)] ring-1 ring-black/5 md:block dark:bg-gray-850 dark:text-white dark:ring-white/10"
|
||||
side="top"
|
||||
align="center"
|
||||
sideOffset={12}
|
||||
aria-label={$i18n.t('File preview')}
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-1.5 border-b border-gray-50/60 px-2 py-1.5 dark:border-gray-800/25"
|
||||
>
|
||||
<FileTypeIcon name={entry.name} size={12} />
|
||||
<div class="min-w-0 flex-1 truncate text-xs font-normal leading-4" title={path}>
|
||||
{entry.name}
|
||||
</div>
|
||||
{#if entry.size !== undefined}
|
||||
<span class="shrink-0 text-[0.625rem] text-gray-500 dark:text-gray-400"
|
||||
>{formatFileSize(entry.size)}</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
<div
|
||||
class="h-60 max-h-[calc(100dvh-6rem)] overflow-hidden text-xs [&_.document-markdown]:!p-2 [&_.document-markdown_h1]:!text-sm [&_.document-markdown_h2]:!text-xs [&_.document-markdown_h3]:!text-xs [&_.document-markdown]:!text-xs [&_.cm-editor]:!text-[0.6875rem] [&_pre]:!text-[0.6875rem]"
|
||||
>
|
||||
{#if documentPreview && withinPreviewLimit}
|
||||
<CommentFilePreview
|
||||
{path}
|
||||
baseUrl={terminalUrl}
|
||||
apiKey={terminalKey}
|
||||
chatId={sessionId ?? null}
|
||||
/>
|
||||
{:else if loading || fileContent !== null || fileImageUrl || fileVideoUrl || fileAudioUrl || fileSqliteData}
|
||||
<FilePreview
|
||||
selectedFile={path}
|
||||
fileLoading={loading}
|
||||
{fileContent}
|
||||
{fileImageUrl}
|
||||
{fileVideoUrl}
|
||||
{fileAudioUrl}
|
||||
{fileSqliteData}
|
||||
baseUrl={terminalUrl}
|
||||
apiKey={terminalKey}
|
||||
chatId={sessionId ?? null}
|
||||
readOnly={true}
|
||||
allowScripts={false}
|
||||
/>
|
||||
{:else}
|
||||
<div class="px-3 py-4 text-xs text-gray-500 dark:text-gray-400">
|
||||
{$i18n.t('Preview not available')}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</LinkPreview.Content>
|
||||
</LinkPreview.Portal>
|
||||
|
|
@ -5,8 +5,7 @@
|
|||
import Dropdown from '$lib/components/common/Dropdown.svelte';
|
||||
import DropdownMenu from '$lib/components/common/DropdownMenu.svelte';
|
||||
import Icon from './Icon.svelte';
|
||||
import Plus from '$lib/components/icons/Plus.svelte';
|
||||
import Folder from '$lib/components/icons/Folder.svelte';
|
||||
import Check from '$lib/components/icons/Check.svelte';
|
||||
|
||||
export let dragType = 'application/x-terminal-file-move';
|
||||
const i18n: any = getContext('i18n');
|
||||
|
|
@ -15,6 +14,8 @@
|
|||
export let selectedFile: string | null = null;
|
||||
export let loading = false;
|
||||
export let writable = true;
|
||||
export let viewMode: 'grid' | 'list' | null = null;
|
||||
export let onViewModeChange: (mode: 'grid' | 'list') => void = () => {};
|
||||
|
||||
export let onNavigate: (path: string) => void = () => {};
|
||||
export let onRefresh: () => void = () => {};
|
||||
|
|
@ -254,7 +255,7 @@
|
|||
}}
|
||||
disabled={!writable}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
<Icon name="file-plus" size={14} strokeWidth={1.5} />
|
||||
<span>{$i18n.t('New File')}</span>
|
||||
</button>
|
||||
<button
|
||||
|
|
@ -266,7 +267,7 @@
|
|||
}}
|
||||
disabled={!writable}
|
||||
>
|
||||
<Folder className="size-3.5" />
|
||||
<Icon name="folder-plus" size={14} strokeWidth={1.5} />
|
||||
<span>{$i18n.t('New Folder')}</span>
|
||||
</button>
|
||||
<button
|
||||
|
|
@ -278,7 +279,7 @@
|
|||
}}
|
||||
disabled={!writable}
|
||||
>
|
||||
<Icon name="upload" size={12} strokeWidth={1.4} />
|
||||
<Icon name="upload-tray" size={14} strokeWidth={1.5} />
|
||||
<span>{$i18n.t('Upload')}</span>
|
||||
</button>
|
||||
{#if onUploadFolder}
|
||||
|
|
@ -291,7 +292,7 @@
|
|||
onUploadFolder?.();
|
||||
}}
|
||||
>
|
||||
<Icon name="upload" size={12} strokeWidth={1.4} />
|
||||
<Icon name="folder-upload" size={14} strokeWidth={1.5} />
|
||||
<span>{$i18n.t('Upload Folder')}</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
|
@ -303,7 +304,7 @@
|
|||
actionsMenuOpen = false;
|
||||
}}
|
||||
>
|
||||
<Icon name="download" size={12} strokeWidth={1.4} />
|
||||
<Icon name="download-tray" size={14} strokeWidth={1.5} />
|
||||
<span>{$i18n.t('Download')}</span>
|
||||
</button>
|
||||
<button
|
||||
|
|
@ -314,9 +315,31 @@
|
|||
actionsMenuOpen = false;
|
||||
}}
|
||||
>
|
||||
<Icon name="eye" size={12} strokeWidth={1.4} />
|
||||
<Icon name={showHidden ? 'eye-off' : 'eye'} size={14} strokeWidth={1.5} />
|
||||
<span>{showHidden ? $i18n.t('Hide Hidden Files') : $i18n.t('Show Hidden Files')}</span>
|
||||
</button>
|
||||
{#if viewMode !== null}
|
||||
<div class="my-1 border-t border-gray-100 dark:border-white/10" role="separator"></div>
|
||||
{#each ['grid', 'list'] as mode}
|
||||
<button
|
||||
type="button"
|
||||
class="select-none flex h-7 w-full items-center gap-2 rounded-lg px-2 text-xs hover:bg-gray-50/40 dark:hover:bg-white/4 transition"
|
||||
aria-pressed={viewMode === mode}
|
||||
on:click={() => {
|
||||
onViewModeChange(mode === 'grid' ? 'grid' : 'list');
|
||||
actionsMenuOpen = false;
|
||||
}}
|
||||
>
|
||||
<Icon name={mode} size={12} strokeWidth={1.4} />
|
||||
<span class="flex-1 text-left"
|
||||
>{mode === 'grid' ? $i18n.t('Grid view') : $i18n.t('List view')}</span
|
||||
>
|
||||
{#if viewMode === mode}
|
||||
<Check className="size-3.5 shrink-0" strokeWidth="2" />
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</Dropdown>
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@
|
|||
stroke-width={strokeWidth}
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class={className}
|
||||
class={`shrink-0 ${className}`}
|
||||
>
|
||||
{#if name === 'folder'}
|
||||
<path
|
||||
|
|
@ -427,6 +427,11 @@
|
|||
<path
|
||||
d="M21.1667 18.5H21.4C21.7314 18.5 22 18.7686 22 19.1V21.4C22 21.7314 21.7314 22 21.4 22H17.6C17.2686 22 17 21.7314 17 21.4V19.1C17 18.7686 17.2686 18.5 17.6 18.5H17.8333M21.1667 18.5V16.75C21.1667 16.1667 20.8333 15 19.5 15C18.1667 15 17.8333 16.1667 17.8333 16.75V18.5M21.1667 18.5H17.8333"
|
||||
/>
|
||||
{:else if name === 'grid'}
|
||||
<rect x="3" y="3" width="6" height="6" rx="1" />
|
||||
<rect x="15" y="3" width="6" height="6" rx="1" />
|
||||
<rect x="3" y="15" width="6" height="6" rx="1" />
|
||||
<rect x="15" y="15" width="6" height="6" rx="1" />
|
||||
{:else if name === 'list'}
|
||||
<path d="M8 6H20" />
|
||||
<path d="M4 6.01L4.01 5.99889" />
|
||||
|
|
@ -487,6 +492,27 @@
|
|||
<path d="M15 6L9 12L15 18" />
|
||||
{:else if name === 'check'}
|
||||
<path d="M5 13L9 17L19 7" />
|
||||
{:else if name === 'file-plus'}
|
||||
<path d="M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9Z" />
|
||||
<path d="M14 3v6h6M9 15h6M12 12v6" />
|
||||
{:else if name === 'folder-plus' || name === 'folder-upload'}
|
||||
<path d="M3 7V5a2 2 0 0 1 2-2h5l3 4h6a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z" />
|
||||
{#if name === 'folder-plus'}
|
||||
<path d="M9 14h6M12 11v6" />
|
||||
{:else}
|
||||
<path d="M12 18v-7m-3 3 3-3 3 3" />
|
||||
{/if}
|
||||
{:else if name === 'upload-tray' || name === 'download-tray'}
|
||||
<path d="M4 16v4h16v-4M12 3v12" />
|
||||
{#if name === 'upload-tray'}
|
||||
<path d="m8 7 4-4 4 4" />
|
||||
{:else}
|
||||
<path d="m8 11 4 4 4-4" />
|
||||
{/if}
|
||||
{:else if name === 'eye-off'}
|
||||
<path
|
||||
d="m3 3 18 18M10.6 6.1A8 8 0 0 1 12 6c4 0 7 3 9 6a18 18 0 0 1-3 3.5M6.4 6.4A21 21 0 0 0 3 12c2 3 5 6 9 6a9 9 0 0 0 5.6-2.1M10 10a3 3 0 0 0 4 4"
|
||||
/>
|
||||
{:else if name === 'upload'}
|
||||
<path d="M6 20H18" />
|
||||
<path d="M12 16V4M12 4L15.5 7.5M12 4L8.5 7.5" />
|
||||
|
|
|
|||
|
|
@ -728,18 +728,18 @@
|
|||
{:else if displayOption === 'grid'}
|
||||
<div
|
||||
class="{groupedNotes.length - 1 !== idx
|
||||
? 'mb-5'
|
||||
: ''} gap-2.5 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5"
|
||||
? 'mb-3'
|
||||
: ''} grid grid-cols-[repeat(auto-fill,minmax(min(160px,100%),1fr))] gap-2.5 p-3"
|
||||
>
|
||||
{#each notesList as note, idx (note.id)}
|
||||
<div
|
||||
class="group flex min-h-32 w-full flex-col rounded-lg bg-gray-50/40 p-2.5 text-left transition hover:bg-gray-100/60 focus-within:bg-gray-100/60 dark:bg-gray-900/30 dark:hover:bg-gray-900 dark:focus-within:bg-gray-900"
|
||||
class="group relative flex h-28 min-w-0 w-full flex-col rounded-md border border-black/[0.06] bg-gray-50/40 p-2 text-left transition-colors duration-75 hover:bg-gray-50/40 focus-within:bg-gray-50/40 dark:border-white/[0.06] dark:bg-gray-900 dark:hover:bg-white/4 dark:focus-within:bg-white/4"
|
||||
>
|
||||
<div class="flex items-start gap-2">
|
||||
<a href={`/notes/${note.id}`} class="min-w-0 flex-1">
|
||||
<div class="flex min-w-0 items-start">
|
||||
<a href={`/notes/${note.id}`} class="min-w-0 flex-1 pr-5">
|
||||
<Tooltip content={note.title} placement="top-start">
|
||||
<div
|
||||
class="truncate text-[0.8125rem] leading-5 text-gray-800 group-hover:underline dark:text-gray-200"
|
||||
class="line-clamp-2 break-words text-xs text-gray-800 dark:text-gray-200"
|
||||
>
|
||||
{note.title}
|
||||
</div>
|
||||
|
|
@ -749,7 +749,7 @@
|
|||
{#if shiftKey}
|
||||
<Tooltip content={$i18n.t('Delete')}>
|
||||
<button
|
||||
class="flex size-5 items-center justify-center rounded-lg text-gray-400 transition hover:text-gray-700 dark:text-gray-500 dark:hover:text-gray-200"
|
||||
class="absolute right-1 top-1 flex size-5 shrink-0 items-center justify-center rounded text-gray-400 transition hover:bg-gray-50/40 hover:text-gray-600 dark:text-gray-500 dark:hover:bg-white/4 dark:hover:text-gray-400"
|
||||
type="button"
|
||||
aria-label={$i18n.t('Delete')}
|
||||
on:click={(e) => {
|
||||
|
|
@ -795,29 +795,29 @@
|
|||
}}
|
||||
>
|
||||
<button
|
||||
class="flex size-5 items-center justify-center rounded-lg text-gray-400 transition hover:text-gray-700 dark:text-gray-500 dark:hover:text-gray-200"
|
||||
class="absolute right-1 top-1 flex size-5 shrink-0 items-center justify-center rounded text-gray-400 transition hover:bg-gray-50/40 hover:text-gray-600 dark:text-gray-500 dark:hover:bg-white/4 dark:hover:text-gray-400"
|
||||
type="button"
|
||||
aria-label={$i18n.t('Note Menu')}
|
||||
>
|
||||
<EllipsisHorizontal className="size-3.5" />
|
||||
<EllipsisHorizontal className="size-3" />
|
||||
</button>
|
||||
</NoteMenu>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<a href={`/notes/${note.id}`} class="mt-1 flex min-h-0 flex-1 flex-col">
|
||||
<div
|
||||
class="line-clamp-3 text-xs leading-5 text-gray-500 dark:text-gray-500"
|
||||
>
|
||||
{#if note.data?.content?.md}
|
||||
{note.data?.content?.md}
|
||||
{:else}
|
||||
{$i18n.t('No content')}
|
||||
{/if}
|
||||
<a
|
||||
href={`/notes/${note.id}`}
|
||||
class="mt-2 min-h-0 flex-1 overflow-hidden text-[0.6875rem] leading-4 text-gray-500 dark:text-gray-400"
|
||||
style="mask-image: linear-gradient(to bottom, black 65%, transparent 100%);"
|
||||
>
|
||||
<div class="line-clamp-4 whitespace-pre-line break-words">
|
||||
{note.data?.content?.md || $i18n.t('No content')}
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a href={`/notes/${note.id}`} class="mt-2 block min-w-0">
|
||||
<div
|
||||
class="mt-auto flex w-full items-center justify-between gap-2 pt-3 text-[0.6875rem] leading-4 text-gray-500 dark:text-gray-500"
|
||||
class="flex min-w-0 w-full items-center justify-between gap-1 text-[0.5625rem] leading-3 text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
<Tooltip
|
||||
content={note?.user?.email ?? $i18n.t('Deleted User')}
|
||||
|
|
@ -836,7 +836,7 @@
|
|||
.locale($i18n.language)
|
||||
.format('LLLL')}
|
||||
>
|
||||
<div class="shrink-0">
|
||||
<div class="max-w-full shrink-0 truncate">
|
||||
{dayjs(note.updated_at / 1000000)
|
||||
.locale($i18n.language)
|
||||
.fromNow()}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue