Files
TREK/client/src/components/Assistant/useAssistantAttach.ts
T
Maurice 8a8ce4013b fix(assistant): green CI round after the shared ChatGPT plan
- Tests that still knew the ChatGPT sign-in as personal only follow the
  admin's shared plan: the service takes the caller with their role, the
  admin tiles show ChatGPT, the controller tests cover the admin routes.
- The table refused an instance ChatGPT connection with a CHECK, so
  sharing the plan failed. A new migration rebuilds ai_connections
  without that check; the shipped migration stays as it was.
- useChatgptConnect grew past the function size limit: reading the
  clipboard and noticing the return to the tab are helpers of their own.
- The file link factory hands its overrides over apart from the row,
  like every other factory, which ends the deep type instantiation.
- Knip: exports only their own file used are private again, and the
  booking cap reads MAX_WRITES_PER_STEP instead of an alias.
- lint:i18n-keys reaches the tools' own action names through a key
  builder and the connection hints through literal keys.
- MMehrSheet stays in the assistant hints pair only.
2026-10-21 21:40:48 +00:00

85 lines
2.6 KiB
TypeScript

import { LLM_PHOTO_EXTENSIONS } from '@trek/shared';
import { type DragEvent, useState } from 'react';
import { useTranslation } from '../../i18n';
import { chatOf, useAssistantStore } from '../../store/assistantStore';
import { useToast } from '../shared/Toast';
/** What the booking import reads (the server checks again), and how big one file may be. */
const ATTACH_EXTENSIONS: readonly string[] = [
'.pdf',
'.eml',
'.pkpass',
'.ics',
'.html',
'.htm',
'.txt',
...LLM_PHOTO_EXTENSIONS,
];
const MAX_ATTACH_BYTES = 10 * 1024 * 1024;
function extensionOf(name: string): string {
const dot = name.lastIndexOf('.');
return dot < 0 ? '' : name.slice(dot).toLowerCase();
}
function hasFiles(e: DragEvent): boolean {
return Array.from(e.dataTransfer.types).includes('Files');
}
/**
* Hands a booking file to the chat, from the paperclip, a paste or a drop on
* the window. One file at a time, refused here with a toast when it is not
* one the import reads or too big, so a wrong file never costs a round trip.
* The drop handlers go on the whole chat; `dragging` lights its drop zone.
*/
export function useAssistantAttach(tripId: number) {
const { t } = useTranslation();
const toast = useToast();
const attach = useAssistantStore((s) => s.attach);
const busy = useAssistantStore((s) => chatOf(s, tripId).isRunning);
const [dragging, setDragging] = useState(false);
const take = (files: FileList | File[] | null | undefined): boolean => {
const file = files ? Array.from(files)[0] : undefined;
if (!file) return false;
if (busy) return true;
if (!ATTACH_EXTENSIONS.includes(extensionOf(file.name))) {
toast.warning(t('assistant.attach.unsupported'));
return true;
}
if (file.size > MAX_ATTACH_BYTES) {
toast.warning(t('assistant.attach.tooLarge'));
return true;
}
void attach(tripId, file);
return true;
};
const dropZone = {
onDragEnter: (e: DragEvent) => {
if (!hasFiles(e)) return;
e.preventDefault();
setDragging(true);
},
onDragOver: (e: DragEvent) => {
if (!hasFiles(e)) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
},
onDragLeave: (e: DragEvent) => {
// Leaving for a child is not leaving the zone.
if (e.currentTarget.contains(e.relatedTarget as Node | null)) return;
setDragging(false);
},
onDrop: (e: DragEvent) => {
if (!hasFiles(e)) return;
e.preventDefault();
setDragging(false);
take(e.dataTransfer.files);
},
};
return { take, busy, dragging, dropZone, accept: ATTACH_EXTENSIONS.join(',') };
}