222 lines
7.7 KiB
Svelte
222 lines
7.7 KiB
Svelte
<script>
|
|
import { getContext } from "svelte";
|
|
import { selection, clipboard, cursor } from "./stores.svelte.js";
|
|
|
|
let { item, scope, feed = false, currentDir = "", onlocate, onfav, onclick, ondblclick, oncontextmenu, ondragstartitem, ondropitem } = $props();
|
|
const apiURL = getContext("pcr-apiURL");
|
|
|
|
// dir relative to the browsed subtree — empty for direct children (no badge)
|
|
let itemDir = $derived.by(() => {
|
|
const dir = item.path.split("/").slice(0, -1).join("/");
|
|
if (!dir || dir === currentDir) return "";
|
|
return currentDir && dir.startsWith(currentDir + "/") ? dir.slice(currentDir.length + 1) : dir;
|
|
});
|
|
|
|
let selected = $derived(selection.items.has(item.path));
|
|
let focused = $derived(cursor.path === item.path);
|
|
let isCut = $derived(clipboard.op === "cut" && clipboard.scope === scope && clipboard.items.some(i => i.path === item.path));
|
|
let dropOver = $state(false);
|
|
let thumbLoaded = false;
|
|
let showFlash = $state(false);
|
|
|
|
function onThumbUpdate() {
|
|
if (thumbLoaded) showFlash = true;
|
|
thumbLoaded = true;
|
|
}
|
|
|
|
// A cover whose /thumb request raced the record commit latches broken with no
|
|
// recovery. Cache-bust and re-fetch on the error event — bounded, no polling.
|
|
function retryThumb(e) {
|
|
const img = e.currentTarget;
|
|
const n = +img.dataset.pcrRetry || 0;
|
|
if (n >= 3) return;
|
|
img.dataset.pcrRetry = String(n + 1);
|
|
const u = new URL(img.src, location.href);
|
|
u.searchParams.set("r", String(n + 1));
|
|
img.src = u.toString();
|
|
}
|
|
|
|
function handleDragOver(e) {
|
|
if (item.type !== "folder") return;
|
|
if (!e.dataTransfer.types.includes("application/x-promptchain-move")) return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
e.dataTransfer.dropEffect = "move";
|
|
dropOver = true;
|
|
}
|
|
|
|
function handleDrop(e) {
|
|
dropOver = false;
|
|
if (item.type !== "folder") return;
|
|
const raw = e.dataTransfer.getData("application/x-promptchain-move");
|
|
if (!raw) return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
try {
|
|
const data = JSON.parse(raw);
|
|
if (data.paths.includes(item.path)) return;
|
|
ondropitem?.(data.paths, data.scope, item.path);
|
|
} catch {}
|
|
}
|
|
</script>
|
|
|
|
<div
|
|
class="pcr-gi"
|
|
class:selected
|
|
class:focused
|
|
class:cut={isCut}
|
|
class:drop-target={dropOver}
|
|
data-item-path={item.path}
|
|
draggable={true}
|
|
ondragstart={ondragstartitem}
|
|
ondragover={handleDragOver}
|
|
ondragleave={() => dropOver = false}
|
|
ondrop={handleDrop}
|
|
role="button"
|
|
tabindex="0"
|
|
{onclick}
|
|
{ondblclick}
|
|
{oncontextmenu}
|
|
title={item.name}
|
|
>
|
|
<div class="pcr-gi-img">
|
|
{#if item.type === "image" || item.type === "video"}
|
|
<img
|
|
src={apiURL(`/promptchain/browse/preview?scope=${scope}&path=${encodeURIComponent(item.path)}&thumb=1`)}
|
|
alt={item.name} loading="lazy" decoding="async" draggable="false"
|
|
/>
|
|
{#if item._flash}
|
|
<div class="pcr-gi-flash" onanimationend={() => item._flash = false}></div>
|
|
{/if}
|
|
{:else if item.type === "workflow" && item.thumbnailHash}
|
|
<img
|
|
src={apiURL(`/promptchain/thumb/${item.thumbnailHash}`)}
|
|
alt={item.name} loading="lazy" draggable="false"
|
|
onload={onThumbUpdate}
|
|
onerror={retryThumb}
|
|
/>
|
|
{#if showFlash}
|
|
<div class="pcr-gi-flash" onanimationend={() => showFlash = false}></div>
|
|
{/if}
|
|
{:else if item.type === "folder"}
|
|
<svg class="pcr-gi-icon pcr-gi-folder" viewBox="0 0 24 24" fill="currentColor">
|
|
<path d="M10 4H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-8l-2-2z"/>
|
|
</svg>
|
|
{:else if item.type === "workflow"}
|
|
<svg class="pcr-gi-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
<rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/>
|
|
<rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/>
|
|
</svg>
|
|
{:else}
|
|
<svg class="pcr-gi-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
|
<polyline points="14 2 14 8 20 8"/>
|
|
</svg>
|
|
{/if}
|
|
{#if feed && itemDir}
|
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
<span
|
|
class="pcr-gi-dir" role="button" tabindex="-1"
|
|
title="Open file location"
|
|
onclick={(e) => { e.stopPropagation(); onlocate?.(item); }}
|
|
>{itemDir}</span>
|
|
{/if}
|
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
<span
|
|
class="pcr-gi-fav" class:faved={item.favorite} role="button" tabindex="-1"
|
|
title={item.favorite ? "Unstar" : "Star"}
|
|
onclick={(e) => { e.stopPropagation(); onfav?.(item); }}
|
|
>★</span>
|
|
</div>
|
|
<div class="pcr-gi-name">{item.name}</div>
|
|
{#if selected}<div class="pcr-gi-check"></div>{/if}
|
|
</div>
|
|
|
|
<style>
|
|
.pcr-gi {
|
|
display: flex; flex-direction: column; align-items: center;
|
|
padding: 4px; border: 2px solid transparent; border-radius: 4px;
|
|
background: transparent; color: var(--input-text, #ccc);
|
|
cursor: pointer; position: relative; overflow: hidden;
|
|
/* offscreen tiles skip paint; intrinsic size keeps scroll height stable */
|
|
content-visibility: auto;
|
|
contain-intrinsic-size: auto 160px;
|
|
}
|
|
.pcr-gi:hover {
|
|
background: rgba(255, 255, 255, 0.06);
|
|
border-color: var(--border-color, #444);
|
|
}
|
|
.pcr-gi.selected {
|
|
background: rgba(79, 195, 247, 0.15);
|
|
border-color: rgba(79, 195, 247, 0.5);
|
|
}
|
|
.pcr-gi.focused { border-color: rgba(255, 255, 255, 0.4); }
|
|
.pcr-gi.cut { border-color: rgba(232, 191, 94, 0.6); }
|
|
.pcr-gi.drop-target {
|
|
background: rgba(79, 195, 247, 0.25);
|
|
border-color: rgba(79, 195, 247, 0.6);
|
|
}
|
|
|
|
.pcr-gi-img {
|
|
width: 100%; aspect-ratio: 1; position: relative;
|
|
display: flex; align-items: center; justify-content: center;
|
|
overflow: hidden; border-radius: 3px; background: rgba(0, 0, 0, 0.2);
|
|
}
|
|
|
|
@keyframes pcr-thumb-flash {
|
|
0% { background: rgba(255, 255, 255, 0.85); }
|
|
100% { background: transparent; }
|
|
}
|
|
.pcr-gi-flash {
|
|
position: absolute; inset: 0;
|
|
animation: pcr-thumb-flash 0.4s ease-out forwards;
|
|
pointer-events: none;
|
|
}
|
|
.pcr-gi-img img { width: 100%; height: 100%; object-fit: cover; }
|
|
|
|
.pcr-gi-icon {
|
|
width: 32px; height: 32px;
|
|
color: var(--input-text, #666); opacity: 0.7;
|
|
}
|
|
.pcr-gi-folder { color: #ffd54f; opacity: 0.9; }
|
|
|
|
.pcr-gi-name {
|
|
width: 100%; margin-top: 4px; font-size: 10px;
|
|
text-align: center; overflow: hidden;
|
|
text-overflow: ellipsis; white-space: nowrap;
|
|
}
|
|
|
|
.pcr-gi-dir {
|
|
position: absolute; left: 4px; bottom: 4px;
|
|
max-width: calc(100% - 8px);
|
|
padding: 1px 5px; border-radius: 7px;
|
|
background: rgba(0, 0, 0, 0.65);
|
|
color: #ddd; font-size: 9px;
|
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
cursor: pointer;
|
|
}
|
|
.pcr-gi-dir:hover { background: rgba(0, 0, 0, 0.85); color: #ff8a25; }
|
|
|
|
.pcr-gi-fav {
|
|
position: absolute; top: 2px; right: 4px;
|
|
font-size: 13px; line-height: 1; padding: 2px;
|
|
color: rgba(255, 255, 255, 0.85); cursor: pointer;
|
|
opacity: 0; text-shadow: 0 0 3px rgba(0, 0, 0, 0.9);
|
|
}
|
|
.pcr-gi:hover .pcr-gi-fav { opacity: 0.6; }
|
|
.pcr-gi-fav:hover { opacity: 1; }
|
|
.pcr-gi-fav.faved { opacity: 1; color: #ffd54f; }
|
|
|
|
.pcr-gi-check {
|
|
position: absolute; top: 4px; left: 4px;
|
|
width: 14px; height: 14px; border-radius: 50%;
|
|
background: var(--p-button-text-primary-color, #4fc3f7);
|
|
display: flex; align-items: center; justify-content: center;
|
|
}
|
|
.pcr-gi-check::after {
|
|
content: ""; width: 6px; height: 3px;
|
|
border-left: 1.5px solid #000; border-bottom: 1.5px solid #000;
|
|
transform: rotate(-45deg); margin-top: -1px;
|
|
}
|
|
</style>
|