vfs
This commit is contained in:
parent
690e4f033c
commit
5fd4f83ce4
@ -1,4 +1,4 @@
|
||||
import React, { useEffect } from "react";
|
||||
import React, { useEffect, useMemo } from "react";
|
||||
import { fetchPostById } from '@/modules/posts/client-posts';
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useWizardContext } from "@/hooks/useWizardContext";
|
||||
@ -69,6 +69,7 @@ import {
|
||||
publishImage as publishImageUtil,
|
||||
quickPublishAsNew as quickPublishAsNewUtil,
|
||||
publishToGallery as publishToGalleryUtil,
|
||||
saveWizardImageAsVfsFile as saveWizardImageAsVfsFileUtil,
|
||||
} from "./ImageWizard/handlers/publishHandlers";
|
||||
import {
|
||||
loadFamilyVersions as loadFamilyVersionsUtil,
|
||||
@ -131,7 +132,7 @@ const ImageWizard: React.FC<ImageWizardProps> = ({
|
||||
initialPostSettings,
|
||||
editingPostId = undefined
|
||||
}) => {
|
||||
const { user } = useAuth();
|
||||
const { user, session } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const { addLog, isLoggerVisible, setLoggerVisible } = useLog();
|
||||
|
||||
@ -1188,6 +1189,25 @@ const ImageWizard: React.FC<ImageWizardProps> = ({
|
||||
setIsPublishing
|
||||
);
|
||||
|
||||
const handleSaveAsVfsFile = () =>
|
||||
saveWizardImageAsVfsFileUtil(
|
||||
{
|
||||
user,
|
||||
generatedImage,
|
||||
images,
|
||||
lightboxOpen,
|
||||
currentImageIndex,
|
||||
postTitle,
|
||||
prompt,
|
||||
isOrgContext: false,
|
||||
orgSlug: null,
|
||||
accessToken: session?.access_token,
|
||||
},
|
||||
setIsPublishing,
|
||||
);
|
||||
|
||||
const hasVfsFileContext = useMemo(() => images.some((img) => img.meta?.vfs), [images]);
|
||||
|
||||
const handleAddToPost = async (imageSrc: string, title: string, description?: string) => {
|
||||
if (!currentEditingPostId) {
|
||||
toast.error("No active post to add to");
|
||||
@ -1683,6 +1703,8 @@ const ImageWizard: React.FC<ImageWizardProps> = ({
|
||||
onPublish={() => setShowLightboxPublishDialog(true)}
|
||||
onPublishToGallery={handlePublishToGallery}
|
||||
onAppendToPost={handleAppendToPost}
|
||||
showSaveAsVfsFile={hasVfsFileContext}
|
||||
onSaveAsVfsFile={handleSaveAsVfsFile}
|
||||
onAddToPost={() => {
|
||||
const currentImage = images[images.length - 1]; // Default to most recent for sidebar action
|
||||
if (currentImage) handleAddToPost(currentImage.src, postTitle || prompt, postDescription)
|
||||
|
||||
@ -75,6 +75,9 @@ interface WizardSidebarProps {
|
||||
onPublish: () => void;
|
||||
onPublishToGallery?: () => void;
|
||||
onAppendToPost?: () => void;
|
||||
/** When true, show “Save as file” in the Quick Publish menu (VFS-backed session). */
|
||||
showSaveAsVfsFile?: boolean;
|
||||
onSaveAsVfsFile?: () => void;
|
||||
onAddToPost?: () => void;
|
||||
editingPostId?: string | null;
|
||||
lastError?: string | null;
|
||||
@ -128,6 +131,8 @@ export const WizardSidebar: React.FC<WizardSidebarProps> = ({
|
||||
onPublish,
|
||||
onPublishToGallery,
|
||||
onAppendToPost,
|
||||
showSaveAsVfsFile,
|
||||
onSaveAsVfsFile,
|
||||
onAddToPost,
|
||||
editingPostId,
|
||||
lastError,
|
||||
@ -441,6 +446,14 @@ export const WizardSidebar: React.FC<WizardSidebarProps> = ({
|
||||
<T>Publish as Picture</T>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{showSaveAsVfsFile && onSaveAsVfsFile && (
|
||||
<DropdownMenuItem
|
||||
onClick={onSaveAsVfsFile}
|
||||
disabled={isPublishing}
|
||||
>
|
||||
<T>Save as file</T>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onAppendToPost && (
|
||||
<DropdownMenuItem onClick={onAppendToPost}>
|
||||
<T>Append to Existing Post</T>
|
||||
|
||||
@ -4,6 +4,8 @@ import { createPost, updatePostDetails } from '@/modules/posts/client-posts';
|
||||
import { createPicture, updatePicture } from '@/modules/posts/client-pictures';
|
||||
import { toast } from 'sonner';
|
||||
import { translate } from '@/i18n';
|
||||
import { uploadVfsFile } from '@/modules/storage/client-vfs';
|
||||
import { nextIterationVfsPath } from '@/modules/storage/vfsWizardBridge';
|
||||
|
||||
/**
|
||||
* Publishing Handlers
|
||||
@ -28,6 +30,8 @@ interface PublishImageOptions {
|
||||
editingPostId?: string;
|
||||
settings?: any;
|
||||
meta?: any;
|
||||
/** Bearer token for VFS upload when saving iterations to the file browser. */
|
||||
accessToken?: string;
|
||||
}
|
||||
|
||||
export const publishImage = async (
|
||||
@ -280,6 +284,77 @@ export const quickPublishAsNew = async (
|
||||
}
|
||||
};
|
||||
|
||||
function getVfsProvenance(images: ImageFile[]): { mount: string; path: string } | null {
|
||||
for (const img of images) {
|
||||
const vfs = img.meta?.vfs as { mount?: string; path?: string } | undefined;
|
||||
if (vfs && typeof vfs.mount === 'string' && typeof vfs.path === 'string') {
|
||||
return { mount: vfs.mount, path: vfs.path };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Same image resolution as quick publish; writes a new file next to the VFS source (see `meta.vfs`). */
|
||||
export const saveWizardImageAsVfsFile = async (
|
||||
options: PublishImageOptions,
|
||||
setIsPublishing: React.Dispatch<React.SetStateAction<boolean>>,
|
||||
) => {
|
||||
const { user, generatedImage, images, lightboxOpen, currentImageIndex, accessToken } = options;
|
||||
|
||||
if (!user) {
|
||||
toast.error(translate('User not authenticated'));
|
||||
return;
|
||||
}
|
||||
|
||||
const vfs = getVfsProvenance(images);
|
||||
if (!vfs) {
|
||||
toast.error(translate('No file browser context'));
|
||||
return;
|
||||
}
|
||||
|
||||
let imageToPublish: string | null = null;
|
||||
let imageFile: File | undefined;
|
||||
|
||||
if (generatedImage) {
|
||||
imageToPublish = generatedImage;
|
||||
} else if (lightboxOpen && images.length > 0 && currentImageIndex < images.length) {
|
||||
const currentImage = images[currentImageIndex];
|
||||
imageToPublish = currentImage.src;
|
||||
imageFile = currentImage.file;
|
||||
} else if (images.length > 0) {
|
||||
const firstImage = images[0];
|
||||
imageToPublish = firstImage.src;
|
||||
imageFile = firstImage.file;
|
||||
}
|
||||
|
||||
if (!imageToPublish) {
|
||||
toast.error(translate('No image available to save'));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPublishing(true);
|
||||
try {
|
||||
let file: File;
|
||||
if (imageFile) {
|
||||
file = imageFile;
|
||||
} else {
|
||||
const res = await fetch(imageToPublish);
|
||||
const blob = await res.blob();
|
||||
file = new File([blob], `wizard-${Date.now()}.png`, { type: blob.type || 'image/png' });
|
||||
}
|
||||
|
||||
const targetPath = nextIterationVfsPath(vfs.path);
|
||||
await uploadVfsFile(vfs.mount, targetPath, file, { accessToken });
|
||||
|
||||
toast.success(translate('Saved to file browser'));
|
||||
} catch (error) {
|
||||
console.error('Error saving image to VFS:', error);
|
||||
toast.error(translate('Failed to save file'));
|
||||
} finally {
|
||||
setIsPublishing(false);
|
||||
}
|
||||
};
|
||||
|
||||
export const publishToGallery = async (
|
||||
options: PublishImageOptions,
|
||||
setIsPublishing: React.Dispatch<React.SetStateAction<boolean>>
|
||||
|
||||
@ -260,6 +260,7 @@
|
||||
"File Browser": "File Browser",
|
||||
"File Tools": "File Tools",
|
||||
"File name": "File name",
|
||||
"FILES": "FILES",
|
||||
"Files": "Files",
|
||||
"Files on disk": "Files on disk",
|
||||
"Fill": "Fill",
|
||||
@ -288,6 +289,7 @@
|
||||
"Google API Key": "Google API Key",
|
||||
"HEX": "HEX",
|
||||
"HIDDEN": "HIDDEN",
|
||||
"HOME": "HOME",
|
||||
"HMI Edit Mode Active": "HMI Edit Mode Active",
|
||||
"HTML Content": "HTML Content",
|
||||
"HTML heading level": "HTML heading level",
|
||||
@ -426,6 +428,7 @@
|
||||
"Open": "Open",
|
||||
"Open in full page": "Open in full page",
|
||||
"Open video": "Open video",
|
||||
"Open with AI": "Open with AI",
|
||||
"OpenAI API Key": "OpenAI API Key",
|
||||
"Operatorswitch": "Operatorswitch",
|
||||
"Optimize": "Optimize",
|
||||
@ -541,6 +544,11 @@
|
||||
"Save Settings": "Save Settings",
|
||||
"Save Signal Plot": "Save Signal Plot",
|
||||
"Save as Widget": "Save as Widget",
|
||||
"Save as file": "Save as file",
|
||||
"Saved to file browser": "Saved to file browser",
|
||||
"Failed to save file": "Failed to save file",
|
||||
"No image available to save": "No image available to save",
|
||||
"No file browser context": "Open an image from the file browser first.",
|
||||
"Save current as template": "Save current as template",
|
||||
"Save to DB": "Save to DB",
|
||||
"Save to DeepL": "Save to DeepL",
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useWizardContext } from '@/hooks/useWizardContext';
|
||||
import ImageLightbox from '@/components/ImageLightbox';
|
||||
import LightboxText from '@/modules/storage/views/LightboxText';
|
||||
import LightboxIframe from '@/modules/storage/views/LightboxIframe';
|
||||
@ -32,6 +34,8 @@ import CopyTransferOptions from './CopyTransferOptions';
|
||||
import CopyConflictDialog from './CopyConflictDialog';
|
||||
import CopyProgressDialog from './CopyProgressDialog';
|
||||
|
||||
import { fetchVfsMounts } from '@/modules/storage/client-vfs';
|
||||
import { mergeVfsPluginContributions, type FileBrowserPluginContext } from '@/modules/storage/plugins';
|
||||
import { useVfsAdapter } from '@/modules/storage/hooks/useVfsAdapter';
|
||||
import { useSelection } from '@/modules/storage/hooks/useSelection';
|
||||
import { useFilePreview } from '@/modules/storage/hooks/useFilePreview';
|
||||
@ -139,6 +143,7 @@ const FileBrowserPanel: React.FC<FileBrowserPanelProps> = ({
|
||||
}) => {
|
||||
|
||||
const { session } = useAuth();
|
||||
const { setWizardImage } = useWizardContext();
|
||||
const fileBrowserCtx = useOptionalFileBrowser();
|
||||
const accessToken = session?.access_token;
|
||||
|
||||
@ -198,12 +203,7 @@ const FileBrowserPanel: React.FC<FileBrowserPanelProps> = ({
|
||||
|
||||
const [availableMounts, setAvailableMounts] = useState<string[]>([]);
|
||||
useEffect(() => {
|
||||
const headers: Record<string, string> = {};
|
||||
if (accessToken) headers['Authorization'] = `Bearer ${accessToken}`;
|
||||
fetch('/api/vfs/mounts', { headers })
|
||||
.then(r => r.ok ? r.json() : [])
|
||||
.then((mounts: { name: string }[]) => setAvailableMounts(mounts.map(m => m.name)))
|
||||
.catch(() => { });
|
||||
void fetchVfsMounts({ accessToken }).then(setAvailableMounts);
|
||||
}, [accessToken]);
|
||||
|
||||
// ── VFS Adapter ───────────────────────────────────────────────
|
||||
@ -736,6 +736,24 @@ const FileBrowserPanel: React.FC<FileBrowserPanelProps> = ({
|
||||
useAppStore.getState().setFileBrowserImmersive(!cur);
|
||||
}, []);
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const pluginCtx = useMemo<FileBrowserPluginContext>(
|
||||
() => ({
|
||||
mount,
|
||||
selected,
|
||||
accessToken,
|
||||
sessionUser: session?.user,
|
||||
navigate,
|
||||
setWizardReturnPath: (path: string) => {
|
||||
setWizardImage(null, path);
|
||||
},
|
||||
}),
|
||||
[mount, selected, accessToken, session?.user, navigate, setWizardImage],
|
||||
);
|
||||
|
||||
const pluginContribution = useMemo(() => mergeVfsPluginContributions(pluginCtx), [pluginCtx]);
|
||||
|
||||
const vfsSpec = useMemo<VfsPanelActionSpec>(() => ({
|
||||
canGoUp,
|
||||
goUp,
|
||||
@ -775,6 +793,7 @@ const FileBrowserPanel: React.FC<FileBrowserPanelProps> = ({
|
||||
layout: layoutMode,
|
||||
fileBrowserImmersive,
|
||||
toggleFileBrowserImmersive: fileBrowserCtx ? toggleFileBrowserImmersive : undefined,
|
||||
...pluginContribution,
|
||||
}), [
|
||||
canGoUp, goUp, fetchDir, currentPath, selectedFile, handleView, allowDownload, selected.length,
|
||||
handleDownload, handleDownloadDir, copyEnabled,
|
||||
@ -784,6 +803,7 @@ const FileBrowserPanel: React.FC<FileBrowserPanelProps> = ({
|
||||
currentGlob, showFolders, isSearchMode, onSearchQueryChange, setSearchOpen,
|
||||
allowPanels, browserSide, addNewTab, switchToDualLayout, switchToSingleLayout, toggleLinkedPanes, linked, layoutMode,
|
||||
fileBrowserImmersive, fileBrowserCtx, toggleFileBrowserImmersive,
|
||||
pluginContribution,
|
||||
]);
|
||||
|
||||
const vfsActionsEnabled = Boolean(panelId && fileBrowserCtx);
|
||||
|
||||
@ -13,6 +13,7 @@ import {
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { Action } from '@/actions/types';
|
||||
import { translate } from '@/i18n';
|
||||
import { fetchVfsMounts } from '@/modules/storage/client-vfs';
|
||||
|
||||
/** Tailwind icon colors aligned with {@link PageRibbonBar} ribbon items. */
|
||||
function vfsIconColor(action: Action): string {
|
||||
@ -40,6 +41,7 @@ function vfsIconColor(action: Action): string {
|
||||
'single-layout': 'text-slate-600 dark:text-slate-400',
|
||||
'link-panes': 'text-orange-600 dark:text-orange-400',
|
||||
'app-fullscreen': 'text-sky-600 dark:text-sky-400',
|
||||
'open-with-ai': 'text-violet-600 dark:text-violet-400',
|
||||
};
|
||||
return bySlug[slug] ?? 'text-blue-600 dark:text-blue-400';
|
||||
}
|
||||
@ -153,12 +155,7 @@ const FileBrowserRibbonBar: React.FC = () => {
|
||||
const [availableMounts, setAvailableMounts] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const headers: Record<string, string> = {};
|
||||
if (accessToken) headers['Authorization'] = `Bearer ${accessToken}`;
|
||||
fetch('/api/vfs/mounts', { headers })
|
||||
.then((r) => (r.ok ? r.json() : []))
|
||||
.then((mounts: { name: string }[]) => setAvailableMounts(mounts.map((m) => m.name)))
|
||||
.catch(() => {});
|
||||
void fetchVfsMounts({ accessToken }).then(setAvailableMounts);
|
||||
}, [accessToken]);
|
||||
|
||||
const panelIdx = useMemo(() => {
|
||||
@ -168,9 +165,6 @@ const FileBrowserRibbonBar: React.FC = () => {
|
||||
|
||||
const onSelectMount = (m: string) => {
|
||||
updatePanel(activeSide, panelIdx, { mount: m, path: '/' });
|
||||
setTimeout(() => {
|
||||
document.querySelector<HTMLElement>('.fb-panel-container')?.focus({ preventScroll: true });
|
||||
}, 50);
|
||||
};
|
||||
|
||||
const vfsRibbonSig = useActionStore((s) =>
|
||||
@ -203,14 +197,14 @@ const FileBrowserRibbonBar: React.FC = () => {
|
||||
<div className="flex flex-col w-full border-b shadow-sm shrink-0 z-40" data-testid="file-browser-ribbon">
|
||||
<div className="flex items-center border-b bg-muted/90 backdrop-blur-sm">
|
||||
<div className="px-4 py-1.5 bg-gradient-to-r from-blue-600 to-blue-500 text-white text-xs font-bold tracking-widest shadow-sm">
|
||||
FILES
|
||||
{translate('FILES')}
|
||||
</div>
|
||||
<div className="flex-1 flex overflow-x-auto scrollbar-none pl-2">
|
||||
<RibbonTab active={tab === 'home'} onClick={() => setTab('home')}>
|
||||
HOME
|
||||
{translate('HOME')}
|
||||
</RibbonTab>
|
||||
<RibbonTab active={tab === 'view'} onClick={() => setTab('view')}>
|
||||
VIEW
|
||||
{translate('VIEW')}
|
||||
</RibbonTab>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -8,6 +8,17 @@ export interface VfsOptions {
|
||||
includeSize?: boolean;
|
||||
}
|
||||
|
||||
/** List configured VFS mount names for the current user. */
|
||||
export const fetchVfsMounts = async (options: VfsOptions = {}): Promise<string[]> => {
|
||||
const headers = options.accessToken ? { Authorization: `Bearer ${options.accessToken}` } : undefined;
|
||||
try {
|
||||
const mounts = await apiClient<{ name: string }[]>('/api/vfs/mounts', { headers, cache: 'no-cache' });
|
||||
return mounts.map((m) => m.name);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const fetchVfsDirectory = async (mount: string, path: string, options: VfsOptions = {}): Promise<INode[]> => {
|
||||
let url = vfsUrl('ls', mount, path);
|
||||
if (options.includeSize) {
|
||||
@ -24,6 +35,34 @@ export const fetchVfsSearch = async (mount: string, path: string, query: string,
|
||||
return apiClient<{ results: INode[] }>(url, { headers, cache: 'no-cache' });
|
||||
};
|
||||
|
||||
/** POST `/api/vfs/upload/{mount}/…` — multipart body (binary-safe). */
|
||||
export function vfsUploadUrl(mount: string, relativePath: string): string {
|
||||
const clean = relativePath.replace(/^\/+/, '');
|
||||
const encoded = clean ? clean.split('/').map((seg) => encodeURIComponent(seg)).join('/') : '';
|
||||
return `${serverUrl}/api/vfs/upload/${encodeURIComponent(mount)}${encoded ? `/${encoded}` : ''}`;
|
||||
}
|
||||
|
||||
export async function uploadVfsFile(
|
||||
mount: string,
|
||||
relativePath: string,
|
||||
file: File | Blob,
|
||||
options: VfsOptions = {},
|
||||
): Promise<{ success: boolean; path: string; size?: number }> {
|
||||
const token = options.accessToken ?? (await getAuthToken());
|
||||
const url = vfsUploadUrl(mount, relativePath);
|
||||
const formData = new FormData();
|
||||
const name = file instanceof File ? file.name : 'image.png';
|
||||
formData.append('file', file, name);
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
const res = await fetch(url, { method: 'POST', headers, body: formData });
|
||||
const data = (await res.json().catch(() => ({}))) as { success?: boolean; path?: string; size?: number; error?: string };
|
||||
if (!res.ok) {
|
||||
throw new Error(typeof data.error === 'string' ? data.error : `VFS upload failed: HTTP ${res.status}`);
|
||||
}
|
||||
return { success: Boolean(data.success), path: data.path ?? relativePath, size: data.size };
|
||||
}
|
||||
|
||||
export const writeVfsFile = async (
|
||||
mount: string,
|
||||
path: string,
|
||||
|
||||
@ -39,6 +39,8 @@ export const VfsActionSlug = {
|
||||
linkPanes: 'link-panes',
|
||||
/** In-app fullscreen: hide app nav/footer for the file browser shell */
|
||||
appFullscreen: 'app-fullscreen',
|
||||
/** Open selected image(s) in the AI Image Wizard (VFS provenance in `ImageFile.meta`) */
|
||||
openWithAi: 'open-with-ai',
|
||||
} as const;
|
||||
|
||||
export type VfsActionSlugKey = keyof typeof VfsActionSlug;
|
||||
|
||||
45
packages/ui/src/modules/storage/plugins/Images.ts
Normal file
45
packages/ui/src/modules/storage/plugins/Images.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import { toast } from 'sonner';
|
||||
import { translate } from '@/i18n';
|
||||
import { getMimeCategory } from '@/modules/storage/helpers';
|
||||
import { vfsSelectionToWizardImages } from '@/modules/storage/vfsWizardBridge';
|
||||
import type { FileBrowserPluginContext, VfsPanelPlugin } from '@/modules/storage/plugins/types';
|
||||
|
||||
/**
|
||||
* Toggle image-related file-browser plugins without touching the panel.
|
||||
* Add more flags here as new image actions ship (e.g. `sendToEditor: true`).
|
||||
*/
|
||||
export const IMAGES_PLUGINS = {
|
||||
/** Ribbon/context: “Open with AI” → `/wizard` with VFS-backed `initialImages`. */
|
||||
openWithAi: true,
|
||||
} as const;
|
||||
|
||||
function contributeOpenWithAi(ctx: FileBrowserPluginContext) {
|
||||
if (!ctx.sessionUser) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const canOpenWithAi =
|
||||
ctx.selected.length > 0 && ctx.selected.every((n) => getMimeCategory(n) === 'image');
|
||||
|
||||
const openWithAi = () => {
|
||||
if (!ctx.sessionUser) {
|
||||
toast.error(translate('Please sign in to use the AI wizard'));
|
||||
return;
|
||||
}
|
||||
if (!canOpenWithAi) return;
|
||||
const initialImages = vfsSelectionToWizardImages(ctx.mount, ctx.selected, ctx.accessToken);
|
||||
ctx.setWizardReturnPath(`${window.location.pathname}${window.location.search}`);
|
||||
ctx.navigate('/wizard', { state: { initialImages } });
|
||||
};
|
||||
|
||||
return { canOpenWithAi, openWithAi };
|
||||
}
|
||||
|
||||
/** Image-focused VFS panel plugins (extend with more entries as needed). */
|
||||
export const imageVfsPlugins: VfsPanelPlugin[] = [
|
||||
{
|
||||
id: 'images.open-with-ai',
|
||||
isEnabled: () => IMAGES_PLUGINS.openWithAi,
|
||||
contribute: contributeOpenWithAi,
|
||||
},
|
||||
];
|
||||
23
packages/ui/src/modules/storage/plugins/index.ts
Normal file
23
packages/ui/src/modules/storage/plugins/index.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import type { FileBrowserPluginContext, VfsPanelPlugin, VfsPanelPluginContribution } from '@/modules/storage/plugins/types';
|
||||
import { imageVfsPlugins } from '@/modules/storage/plugins/Images';
|
||||
|
||||
/** All registered plugins (image, future: audio, code, …). Order matters on key collision — last wins. */
|
||||
export const vfsPanelPlugins: VfsPanelPlugin[] = [...imageVfsPlugins];
|
||||
|
||||
export type { FileBrowserPluginContext, VfsPanelPlugin, VfsPanelPluginContribution } from '@/modules/storage/plugins/types';
|
||||
export { IMAGES_PLUGINS, imageVfsPlugins } from '@/modules/storage/plugins/Images';
|
||||
|
||||
/**
|
||||
* Merge enabled plugin contributions into one partial {@link VfsPanelActionSpec} overlay.
|
||||
*/
|
||||
export function mergeVfsPluginContributions(ctx: FileBrowserPluginContext): VfsPanelPluginContribution {
|
||||
const out: VfsPanelPluginContribution = {};
|
||||
for (const plugin of vfsPanelPlugins) {
|
||||
if (!plugin.isEnabled()) continue;
|
||||
const contribution = plugin.contribute(ctx);
|
||||
if (contribution) {
|
||||
Object.assign(out, contribution);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
31
packages/ui/src/modules/storage/plugins/types.ts
Normal file
31
packages/ui/src/modules/storage/plugins/types.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import type { NavigateFunction } from 'react-router-dom';
|
||||
import type { User } from '@supabase/supabase-js';
|
||||
import type { INode } from '@/modules/storage/types';
|
||||
import type { VfsPanelActionSpec } from '@/modules/storage/useRegisterVfsPanelActions';
|
||||
|
||||
/**
|
||||
* Context passed to file-browser VFS plugins (selection, auth, navigation).
|
||||
* Keep this minimal; extend when new plugin kinds need more data.
|
||||
*/
|
||||
export interface FileBrowserPluginContext {
|
||||
mount: string;
|
||||
selected: INode[];
|
||||
accessToken?: string;
|
||||
sessionUser: User | undefined;
|
||||
navigate: NavigateFunction;
|
||||
/** Clears wizard stash and sets return path for `/wizard` close (see {@link useWizardContext}). */
|
||||
setWizardReturnPath: (path: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Partial overlay onto {@link VfsPanelActionSpec}. Plugins should only set keys they own;
|
||||
* later plugins in the list override earlier ones on key collision.
|
||||
*/
|
||||
export type VfsPanelPluginContribution = Partial<VfsPanelActionSpec>;
|
||||
|
||||
export interface VfsPanelPlugin {
|
||||
id: string;
|
||||
/** When false, {@link contribute} is not called. */
|
||||
isEnabled: () => boolean;
|
||||
contribute: (ctx: FileBrowserPluginContext) => VfsPanelPluginContribution | undefined;
|
||||
}
|
||||
@ -23,6 +23,7 @@ import {
|
||||
Unlink,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
Wand2,
|
||||
} from 'lucide-react';
|
||||
import { useActionStore } from '@/actions/store';
|
||||
import type { Action } from '@/actions/types';
|
||||
@ -33,6 +34,7 @@ import {
|
||||
type VfsRibbonTabId,
|
||||
} from '@/modules/storage/file-browser-commands';
|
||||
import type { SortKey } from '@/modules/storage/types';
|
||||
import { translate } from '@/i18n';
|
||||
|
||||
export interface VfsPanelActionSpec {
|
||||
canGoUp: boolean;
|
||||
@ -73,6 +75,10 @@ export interface VfsPanelActionSpec {
|
||||
/** In-app fullscreen (app shell hidden) — not browser Fullscreen API */
|
||||
fileBrowserImmersive?: boolean;
|
||||
toggleFileBrowserImmersive?: () => void;
|
||||
|
||||
/** When set, registers “Open with AI” for image selection (`vfsSelectionToWizardImages`). */
|
||||
canOpenWithAi?: boolean;
|
||||
openWithAi?: () => void;
|
||||
}
|
||||
|
||||
function reg(
|
||||
@ -192,6 +198,22 @@ export function useRegisterVfsPanelActions(panelId: string | undefined, enabled:
|
||||
),
|
||||
);
|
||||
}
|
||||
if (spec.openWithAi) {
|
||||
push(
|
||||
reg(
|
||||
panelId,
|
||||
VfsActionSlug.openWithAi,
|
||||
translate('Open with AI'),
|
||||
'VFS · AI',
|
||||
Wand2,
|
||||
spec.openWithAi,
|
||||
{
|
||||
disabled: !spec.canOpenWithAi,
|
||||
ribbonTab: 'home',
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
push(
|
||||
reg(panelId, VfsActionSlug.open, 'Open', 'VFS · Open', FolderOpen, spec.openSelected, {
|
||||
disabled: !spec.canOpen,
|
||||
|
||||
41
packages/ui/src/modules/storage/vfsWizardBridge.ts
Normal file
41
packages/ui/src/modules/storage/vfsWizardBridge.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import type { ImageFile } from '@/components/ImageWizard/types';
|
||||
import type { INode } from '@/modules/storage/types';
|
||||
import { vfsUrl } from '@/modules/storage/helpers';
|
||||
|
||||
/** Carried on {@link ImageFile.meta} so wizard save paths can write back to VFS instead of buckets. */
|
||||
export type VfsWizardSourceMeta = { vfs: { mount: string; path: string } };
|
||||
|
||||
/**
|
||||
* Build {@link ImageFile} rows for `/wizard` from the current VFS selection.
|
||||
* Uses the same authenticated GET URL shape as the file browser preview.
|
||||
*/
|
||||
/** New filename next to the source (same directory), e.g. `shots/a.png` → `shots/a-iter-1730000000000.png`. */
|
||||
export function nextIterationVfsPath(sourcePath: string): string {
|
||||
const clean = sourcePath.replace(/^\/+/, '');
|
||||
const lastSlash = clean.lastIndexOf('/');
|
||||
const dir = lastSlash >= 0 ? clean.slice(0, lastSlash) : '';
|
||||
const base = lastSlash >= 0 ? clean.slice(lastSlash + 1) : clean;
|
||||
const dot = base.lastIndexOf('.');
|
||||
const ext = dot >= 0 ? base.slice(dot) : '.png';
|
||||
const stem = dot >= 0 ? base.slice(0, dot) : base;
|
||||
const name = `${stem}-iter-${Date.now()}${ext}`;
|
||||
return dir ? `${dir}/${name}` : name;
|
||||
}
|
||||
|
||||
export function vfsSelectionToWizardImages(mount: string, nodes: INode[], accessToken?: string): ImageFile[] {
|
||||
return nodes.map((node, i) => {
|
||||
const tokenParam = accessToken ? `token=${encodeURIComponent(accessToken)}` : '';
|
||||
const base = vfsUrl('get', mount, node.path);
|
||||
let url = tokenParam ? `${base}?${tokenParam}` : base;
|
||||
if (node.mtime) {
|
||||
url += (url.includes('?') ? '&' : '?') + `t=${node.mtime}`;
|
||||
}
|
||||
return {
|
||||
id: `vfs:${mount}:${node.path}`,
|
||||
src: url,
|
||||
title: node.name,
|
||||
selected: i === 0,
|
||||
meta: { vfs: { mount, path: node.path } } satisfies VfsWizardSourceMeta,
|
||||
};
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user