feat(frontend): UI archivos en cards (issue 0128)
- CardFilesPanel: tab Archivos con grid thumbs + boton subir/borrar - CardForm: drag&drop en descripcion, inserta ref markdown en cursor - CardChatPanel: drag&drop + boton paperclip, sube y envia ref como mensaje - MessageBody: renderer markdown minimo (img inline + link chip) - api.ts: listCardFiles, uploadCardFile (multipart), deleteCardFile - types.ts: CardFile
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import type {
|
import type {
|
||||||
Board,
|
Board,
|
||||||
Card,
|
Card,
|
||||||
|
CardFile,
|
||||||
CardHistoryResponse,
|
CardHistoryResponse,
|
||||||
CardMessage,
|
CardMessage,
|
||||||
Column,
|
Column,
|
||||||
@@ -380,6 +381,42 @@ export function listRequesters(): Promise<string[]> {
|
|||||||
return fetchJSON("/requesters");
|
return fetchJSON("/requesters");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Files (issue 0128) -----------------------------------------------------
|
||||||
|
|
||||||
|
export function listCardFiles(cardId: string): Promise<CardFile[]> {
|
||||||
|
return fetchJSON(`/cards/${cardId}/files`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadCardFile(
|
||||||
|
cardId: string,
|
||||||
|
file: File,
|
||||||
|
source: "upload" | "description" | "chat" = "upload"
|
||||||
|
): Promise<CardFile> {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("file", file);
|
||||||
|
fd.append("source", source);
|
||||||
|
const res = await fetch(`${BASE}/cards/${cardId}/files`, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "same-origin",
|
||||||
|
body: fd,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
let msg = `upload failed: ${res.status}`;
|
||||||
|
try {
|
||||||
|
const body = (await res.json()) as { Message?: string; message?: string };
|
||||||
|
if (body.Message || body.message) msg = body.Message || body.message || msg;
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
throw new HTTPError(res.status, msg);
|
||||||
|
}
|
||||||
|
return (await res.json()) as CardFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteCardFile(fileId: string): Promise<void> {
|
||||||
|
return fetchJSON(`/files/${fileId}`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
|
||||||
export function getMetrics(f: MetricsFilter): Promise<Metrics> {
|
export function getMetrics(f: MetricsFilter): Promise<Metrics> {
|
||||||
const qs = new URLSearchParams();
|
const qs = new URLSearchParams();
|
||||||
if (f.from) qs.set("from", f.from);
|
if (f.from) qs.set("from", f.from);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
ActionIcon,
|
ActionIcon,
|
||||||
Avatar,
|
Avatar,
|
||||||
Box,
|
Box,
|
||||||
|
FileButton,
|
||||||
Group,
|
Group,
|
||||||
Loader,
|
Loader,
|
||||||
Paper,
|
Paper,
|
||||||
@@ -11,26 +12,35 @@ import {
|
|||||||
Textarea,
|
Textarea,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { IconSend, IconTrash } from "@tabler/icons-react";
|
import { IconPaperclip, IconSend, IconTrash } from "@tabler/icons-react";
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import { KeyboardEvent, useCallback, useEffect, useRef, useState } from "react";
|
import { DragEvent, KeyboardEvent, useCallback, useEffect, useRef, useState } from "react";
|
||||||
import * as api from "../api";
|
import * as api from "../api";
|
||||||
import type { CardMessage, User } from "../types";
|
import type { CardMessage, User } from "../types";
|
||||||
import { tagColor } from "./colors";
|
import { tagColor } from "./colors";
|
||||||
import { formatDateTimeShort } from "./format";
|
import { formatDateTimeShort } from "./format";
|
||||||
|
import { MessageBody } from "./MessageBody";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
cardId: string;
|
cardId: string;
|
||||||
users: User[];
|
users: User[];
|
||||||
currentUserId?: string;
|
currentUserId?: string;
|
||||||
onMessagesChange?: (messages: CardMessage[]) => void;
|
onMessagesChange?: (messages: CardMessage[]) => void;
|
||||||
|
onFileUploaded?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CardChatPanel({ cardId, users, currentUserId, onMessagesChange }: Props) {
|
function refForFile(filename: string, url: string, mime: string): string {
|
||||||
|
const safe = filename.replace(/]/g, "");
|
||||||
|
return mime.startsWith("image/") ? `` : `[${safe}](${url})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardChatPanel({ cardId, users, currentUserId, onMessagesChange, onFileUploaded }: Props) {
|
||||||
const [messages, setMessages] = useState<CardMessage[]>([]);
|
const [messages, setMessages] = useState<CardMessage[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [body, setBody] = useState("");
|
const [body, setBody] = useState("");
|
||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [dragOver, setDragOver] = useState(false);
|
||||||
const viewportRef = useRef<HTMLDivElement | null>(null);
|
const viewportRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
const usersById = new Map(users.map((u) => [u.id, u]));
|
const usersById = new Map(users.map((u) => [u.id, u]));
|
||||||
@@ -92,8 +102,62 @@ export function CardChatPanel({ cardId, users, currentUserId, onMessagesChange }
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleFiles = async (files: FileList | File[]) => {
|
||||||
|
setUploading(true);
|
||||||
|
try {
|
||||||
|
for (const file of Array.from(files)) {
|
||||||
|
try {
|
||||||
|
const cf = await api.uploadCardFile(cardId, file, "chat");
|
||||||
|
const ref = refForFile(cf.filename, cf.url, cf.mime);
|
||||||
|
const m = await api.createCardMessage(cardId, ref);
|
||||||
|
setMessages((prev) => {
|
||||||
|
const next = [...prev, m];
|
||||||
|
onMessagesChange?.(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
onFileUploaded?.();
|
||||||
|
} catch (e) {
|
||||||
|
notifications.show({ color: "red", message: `${file.name}: ${(e as Error).message}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDrop = (e: DragEvent<HTMLDivElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragOver(false);
|
||||||
|
if (!e.dataTransfer.files || e.dataTransfer.files.length === 0) return;
|
||||||
|
handleFiles(e.dataTransfer.files);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDragOver = (e: DragEvent<HTMLDivElement>) => {
|
||||||
|
if (!e.dataTransfer.types.includes("Files")) return;
|
||||||
|
e.preventDefault();
|
||||||
|
setDragOver(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDragLeave = (e: DragEvent<HTMLDivElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragOver(false);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="xs" style={{ height: "100%", minHeight: 0 }}>
|
<Stack
|
||||||
|
gap="xs"
|
||||||
|
style={{
|
||||||
|
height: "100%",
|
||||||
|
minHeight: 0,
|
||||||
|
position: "relative",
|
||||||
|
outline: dragOver ? "2px dashed var(--mantine-color-blue-5)" : undefined,
|
||||||
|
outlineOffset: dragOver ? -2 : undefined,
|
||||||
|
borderRadius: 4,
|
||||||
|
}}
|
||||||
|
onDrop={onDrop}
|
||||||
|
onDragOver={onDragOver}
|
||||||
|
onDragLeave={onDragLeave}
|
||||||
|
>
|
||||||
<ScrollArea
|
<ScrollArea
|
||||||
viewportRef={viewportRef}
|
viewportRef={viewportRef}
|
||||||
style={{ flex: 1, minHeight: 200 }}
|
style={{ flex: 1, minHeight: 200 }}
|
||||||
@@ -104,7 +168,7 @@ export function CardChatPanel({ cardId, users, currentUserId, onMessagesChange }
|
|||||||
<Group justify="center" p="md"><Loader size="sm" /></Group>
|
<Group justify="center" p="md"><Loader size="sm" /></Group>
|
||||||
) : messages.length === 0 ? (
|
) : messages.length === 0 ? (
|
||||||
<Text size="sm" c="dimmed" ta="center" p="md">
|
<Text size="sm" c="dimmed" ta="center" p="md">
|
||||||
Sin mensajes aun. Escribe el primero.
|
Sin mensajes aun. Escribe el primero o arrastra un archivo.
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
<Stack gap={6} p={4}>
|
<Stack gap={6} p={4}>
|
||||||
@@ -138,9 +202,9 @@ export function CardChatPanel({ cardId, users, currentUserId, onMessagesChange }
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
<Text size="sm" style={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}>
|
<Stack gap={4}>
|
||||||
{m.body}
|
<MessageBody text={m.body} />
|
||||||
</Text>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
</Group>
|
</Group>
|
||||||
</Paper>
|
</Paper>
|
||||||
@@ -154,13 +218,29 @@ export function CardChatPanel({ cardId, users, currentUserId, onMessagesChange }
|
|||||||
value={body}
|
value={body}
|
||||||
onChange={(e) => setBody(e.currentTarget.value)}
|
onChange={(e) => setBody(e.currentTarget.value)}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
placeholder="Escribe un mensaje (Enter = enviar, Shift+Enter = salto)"
|
placeholder="Escribe un mensaje. Arrastra archivos o usa el clip."
|
||||||
autosize
|
autosize
|
||||||
minRows={1}
|
minRows={1}
|
||||||
maxRows={6}
|
maxRows={6}
|
||||||
style={{ flex: 1 }}
|
style={{ flex: 1 }}
|
||||||
disabled={sending}
|
disabled={sending}
|
||||||
/>
|
/>
|
||||||
|
<FileButton onChange={(file) => file && handleFiles([file])} disabled={uploading}>
|
||||||
|
{(props) => (
|
||||||
|
<Tooltip label="Adjuntar archivo" withArrow>
|
||||||
|
<ActionIcon
|
||||||
|
size="lg"
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
aria-label="Adjuntar"
|
||||||
|
loading={uploading}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<IconPaperclip size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</FileButton>
|
||||||
<Tooltip label="Enviar" withArrow>
|
<Tooltip label="Enviar" withArrow>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
size="lg"
|
size="lg"
|
||||||
@@ -174,6 +254,24 @@ export function CardChatPanel({ cardId, users, currentUserId, onMessagesChange }
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Group>
|
</Group>
|
||||||
|
{(dragOver || uploading) && (
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
background: "rgba(34,139,230,0.08)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
pointerEvents: "none",
|
||||||
|
borderRadius: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text size="sm" fw={500} c="blue">
|
||||||
|
{uploading ? "Subiendo..." : "Suelta para adjuntar"}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { Box, Divider, Group, Tabs, Text } from "@mantine/core";
|
import { Box, Divider, Group, Tabs } from "@mantine/core";
|
||||||
import { IconLink, IconMessage, IconPaperclip } from "@tabler/icons-react";
|
import { IconLink, IconMessage, IconPaperclip } from "@tabler/icons-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import type { Card, CardMessage, User } from "../types";
|
import type { Card, CardMessage, User } from "../types";
|
||||||
import { CardChatPanel } from "./CardChatPanel";
|
import { CardChatPanel } from "./CardChatPanel";
|
||||||
|
import { CardFilesPanel } from "./CardFilesPanel";
|
||||||
import { CardLinksPanel } from "./CardLinksPanel";
|
import { CardLinksPanel } from "./CardLinksPanel";
|
||||||
import { CardForm, CardFormValues } from "./CardForm";
|
import { CardForm, CardFormValues } from "./CardForm";
|
||||||
|
|
||||||
@@ -27,12 +28,15 @@ export function CardEditPanel({
|
|||||||
}: Props) {
|
}: Props) {
|
||||||
const [messages, setMessages] = useState<CardMessage[]>([]);
|
const [messages, setMessages] = useState<CardMessage[]>([]);
|
||||||
const [liveCard, setLiveCard] = useState(card);
|
const [liveCard, setLiveCard] = useState(card);
|
||||||
|
const [filesRefreshKey, setFilesRefreshKey] = useState(0);
|
||||||
|
|
||||||
const wrappedSubmit = async (v: CardFormValues) => {
|
const wrappedSubmit = async (v: CardFormValues) => {
|
||||||
setLiveCard((c) => ({ ...c, title: v.title, description: v.description, requester: v.requester, tags: v.tags, assignee_id: v.assignee_id }));
|
setLiveCard((c) => ({ ...c, title: v.title, description: v.description, requester: v.requester, tags: v.tags, assignee_id: v.assignee_id }));
|
||||||
await onSubmit(v);
|
await onSubmit(v);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const bumpFiles = () => setFilesRefreshKey((k) => k + 1);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Group align="stretch" gap="md" wrap="nowrap" style={{ minHeight: 460 }}>
|
<Group align="stretch" gap="md" wrap="nowrap" style={{ minHeight: 460 }}>
|
||||||
<Box style={{ flex: "1 1 0", minWidth: 320 }}>
|
<Box style={{ flex: "1 1 0", minWidth: 320 }}>
|
||||||
@@ -48,6 +52,8 @@ export function CardEditPanel({
|
|||||||
tags: liveCard.tags || [],
|
tags: liveCard.tags || [],
|
||||||
}}
|
}}
|
||||||
submitLabel="Guardar"
|
submitLabel="Guardar"
|
||||||
|
cardId={liveCard.id}
|
||||||
|
onFileUploaded={bumpFiles}
|
||||||
onSubmit={wrappedSubmit}
|
onSubmit={wrappedSubmit}
|
||||||
onCancel={onCancel}
|
onCancel={onCancel}
|
||||||
/>
|
/>
|
||||||
@@ -58,7 +64,7 @@ export function CardEditPanel({
|
|||||||
<Tabs.List>
|
<Tabs.List>
|
||||||
<Tabs.Tab value="chat" leftSection={<IconMessage size={14} />}>Chat</Tabs.Tab>
|
<Tabs.Tab value="chat" leftSection={<IconMessage size={14} />}>Chat</Tabs.Tab>
|
||||||
<Tabs.Tab value="links" leftSection={<IconLink size={14} />}>Enlaces</Tabs.Tab>
|
<Tabs.Tab value="links" leftSection={<IconLink size={14} />}>Enlaces</Tabs.Tab>
|
||||||
<Tabs.Tab value="files" leftSection={<IconPaperclip size={14} />} disabled>Archivos</Tabs.Tab>
|
<Tabs.Tab value="files" leftSection={<IconPaperclip size={14} />}>Archivos</Tabs.Tab>
|
||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
<Box pt="xs" style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
|
<Box pt="xs" style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
|
||||||
<Tabs.Panel value="chat" style={{ flex: 1, minHeight: 0, display: "flex" }}>
|
<Tabs.Panel value="chat" style={{ flex: 1, minHeight: 0, display: "flex" }}>
|
||||||
@@ -68,6 +74,7 @@ export function CardEditPanel({
|
|||||||
users={users}
|
users={users}
|
||||||
currentUserId={currentUserId}
|
currentUserId={currentUserId}
|
||||||
onMessagesChange={setMessages}
|
onMessagesChange={setMessages}
|
||||||
|
onFileUploaded={bumpFiles}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
@@ -75,9 +82,7 @@ export function CardEditPanel({
|
|||||||
<CardLinksPanel card={liveCard} messages={messages} />
|
<CardLinksPanel card={liveCard} messages={messages} />
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
<Tabs.Panel value="files">
|
<Tabs.Panel value="files">
|
||||||
<Text size="sm" c="dimmed" ta="center" p="md">
|
<CardFilesPanel cardId={liveCard.id} refreshKey={filesRefreshKey} />
|
||||||
Proximamente: adjuntos de archivos.
|
|
||||||
</Text>
|
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
</Box>
|
</Box>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|||||||
@@ -0,0 +1,223 @@
|
|||||||
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Anchor,
|
||||||
|
Badge,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
FileButton,
|
||||||
|
Group,
|
||||||
|
Image,
|
||||||
|
Loader,
|
||||||
|
Paper,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
Tooltip,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { notifications } from "@mantine/notifications";
|
||||||
|
import {
|
||||||
|
IconDownload,
|
||||||
|
IconFile,
|
||||||
|
IconFileSpreadsheet,
|
||||||
|
IconFileText,
|
||||||
|
IconFileTypePdf,
|
||||||
|
IconPhoto,
|
||||||
|
IconTrash,
|
||||||
|
IconUpload,
|
||||||
|
} from "@tabler/icons-react";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import * as api from "../api";
|
||||||
|
import type { CardFile } from "../types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
cardId: string;
|
||||||
|
refreshKey?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSize(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isImage(mime: string): boolean {
|
||||||
|
return mime.startsWith("image/");
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileIcon(mime: string, size = 18) {
|
||||||
|
const m = mime.toLowerCase();
|
||||||
|
if (m.startsWith("image/")) return <IconPhoto size={size} />;
|
||||||
|
if (m === "application/pdf") return <IconFileTypePdf size={size} />;
|
||||||
|
if (
|
||||||
|
m.includes("spreadsheet") ||
|
||||||
|
m.includes("excel") ||
|
||||||
|
m === "text/csv" ||
|
||||||
|
m === "application/vnd.ms-excel"
|
||||||
|
) {
|
||||||
|
return <IconFileSpreadsheet size={size} />;
|
||||||
|
}
|
||||||
|
if (m.startsWith("text/")) return <IconFileText size={size} />;
|
||||||
|
return <IconFile size={size} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceBadge(s: CardFile["source"]) {
|
||||||
|
if (s === "description") return { color: "blue", label: "descripcion" };
|
||||||
|
if (s === "chat") return { color: "teal", label: "chat" };
|
||||||
|
return { color: "gray", label: "subido" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardFilesPanel({ cardId, refreshKey }: Props) {
|
||||||
|
const [files, setFiles] = useState<CardFile[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
|
||||||
|
const reload = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const list = await api.listCardFiles(cardId);
|
||||||
|
setFiles(list);
|
||||||
|
} catch (e) {
|
||||||
|
notifications.show({ color: "red", message: (e as Error).message });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [cardId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
reload();
|
||||||
|
}, [reload, refreshKey]);
|
||||||
|
|
||||||
|
const onUpload = async (file: File | null) => {
|
||||||
|
if (!file) return;
|
||||||
|
setUploading(true);
|
||||||
|
try {
|
||||||
|
const cf = await api.uploadCardFile(cardId, file, "upload");
|
||||||
|
setFiles((prev) => [...prev, cf]);
|
||||||
|
} catch (e) {
|
||||||
|
notifications.show({ color: "red", message: (e as Error).message });
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDelete = async (id: string) => {
|
||||||
|
if (!window.confirm("¿Borrar este archivo?")) return;
|
||||||
|
try {
|
||||||
|
await api.deleteCardFile(id);
|
||||||
|
setFiles((prev) => prev.filter((f) => f.id !== id));
|
||||||
|
} catch (e) {
|
||||||
|
notifications.show({ color: "red", message: (e as Error).message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="xs" p={4}>
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{files.length} archivo{files.length === 1 ? "" : "s"}
|
||||||
|
</Text>
|
||||||
|
<FileButton onChange={onUpload} disabled={uploading}>
|
||||||
|
{(props) => (
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="light"
|
||||||
|
leftSection={<IconUpload size={14} />}
|
||||||
|
loading={uploading}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
Subir
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</FileButton>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<Group justify="center" p="md">
|
||||||
|
<Loader size="sm" />
|
||||||
|
</Group>
|
||||||
|
) : files.length === 0 ? (
|
||||||
|
<Stack gap="xs" p="md" align="center" justify="center" style={{ minHeight: 160 }}>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Sin archivos
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed" ta="center">
|
||||||
|
Sube archivos con el boton, arrastra al chat o a la descripcion.
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<Stack gap={6}>
|
||||||
|
{files.map((f) => {
|
||||||
|
const badge = sourceBadge(f.source);
|
||||||
|
return (
|
||||||
|
<Paper key={f.id} withBorder p="xs" radius="sm">
|
||||||
|
<Group gap="xs" wrap="nowrap" align="flex-start">
|
||||||
|
{isImage(f.mime) ? (
|
||||||
|
<Anchor href={f.url} target="_blank" rel="noopener noreferrer">
|
||||||
|
<Image
|
||||||
|
src={f.url}
|
||||||
|
alt={f.filename}
|
||||||
|
w={64}
|
||||||
|
h={64}
|
||||||
|
fit="cover"
|
||||||
|
radius="sm"
|
||||||
|
/>
|
||||||
|
</Anchor>
|
||||||
|
) : (
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
width: 64,
|
||||||
|
height: 64,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
background: "var(--mantine-color-gray-1)",
|
||||||
|
borderRadius: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{fileIcon(f.mime, 28)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Anchor href={f.url} target="_blank" rel="noopener noreferrer" size="sm" style={{ wordBreak: "break-all" }}>
|
||||||
|
{f.filename}
|
||||||
|
</Anchor>
|
||||||
|
<Group gap={6} mt={4}>
|
||||||
|
<Badge size="xs" variant="light" color={badge.color}>
|
||||||
|
{badge.label}
|
||||||
|
</Badge>
|
||||||
|
<Text size="xs" c="dimmed">{formatSize(f.size)}</Text>
|
||||||
|
<Text size="xs" c="dimmed">{f.mime || "?"}</Text>
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
<Group gap={4} wrap="nowrap">
|
||||||
|
<Tooltip label="Descargar" withArrow>
|
||||||
|
<ActionIcon
|
||||||
|
component="a"
|
||||||
|
href={f.url}
|
||||||
|
download={f.filename}
|
||||||
|
variant="subtle"
|
||||||
|
size="sm"
|
||||||
|
aria-label="Descargar"
|
||||||
|
>
|
||||||
|
<IconDownload size={14} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip label="Borrar" withArrow>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="red"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onDelete(f.id)}
|
||||||
|
aria-label="Borrar"
|
||||||
|
>
|
||||||
|
<IconTrash size={14} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { Autocomplete, Button, Group, Select, Stack, TagsInput, Textarea } from "@mantine/core";
|
import { Autocomplete, Box, Button, Group, Select, Stack, TagsInput, Text, Textarea } from "@mantine/core";
|
||||||
import { FormEvent, KeyboardEvent, useState } from "react";
|
import { notifications } from "@mantine/notifications";
|
||||||
|
import { DragEvent, FormEvent, KeyboardEvent, useRef, useState } from "react";
|
||||||
|
import * as api from "../api";
|
||||||
import type { User } from "../types";
|
import type { User } from "../types";
|
||||||
|
|
||||||
// CardForm: Solicitante (Autocomplete) intencionadamente NO hace submit en Enter.
|
// CardForm: Solicitante (Autocomplete) intencionadamente NO hace submit en Enter.
|
||||||
@@ -21,16 +23,25 @@ interface Props {
|
|||||||
users?: User[];
|
users?: User[];
|
||||||
requesterOptions?: string[];
|
requesterOptions?: string[];
|
||||||
tagOptions?: string[];
|
tagOptions?: string[];
|
||||||
|
cardId?: string;
|
||||||
|
onFileUploaded?: () => void;
|
||||||
onSubmit: (v: CardFormValues) => Promise<void> | void;
|
onSubmit: (v: CardFormValues) => Promise<void> | void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function markdownRef(filename: string, url: string, isImage: boolean): string {
|
||||||
|
const safeName = filename.replace(/]/g, "");
|
||||||
|
return isImage ? `` : `[${safeName}](${url})`;
|
||||||
|
}
|
||||||
|
|
||||||
export function CardForm({
|
export function CardForm({
|
||||||
initial,
|
initial,
|
||||||
submitLabel = "Guardar",
|
submitLabel = "Guardar",
|
||||||
users = [],
|
users = [],
|
||||||
requesterOptions = [],
|
requesterOptions = [],
|
||||||
tagOptions = [],
|
tagOptions = [],
|
||||||
|
cardId,
|
||||||
|
onFileUploaded,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onCancel,
|
onCancel,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
@@ -39,6 +50,9 @@ export function CardForm({
|
|||||||
const [description, setDescription] = useState(initial?.description ?? "");
|
const [description, setDescription] = useState(initial?.description ?? "");
|
||||||
const [assigneeId, setAssigneeId] = useState<string | null>(initial?.assignee_id ?? null);
|
const [assigneeId, setAssigneeId] = useState<string | null>(initial?.assignee_id ?? null);
|
||||||
const [tags, setTags] = useState<string[]>(initial?.tags ?? []);
|
const [tags, setTags] = useState<string[]>(initial?.tags ?? []);
|
||||||
|
const [dragOver, setDragOver] = useState(false);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
const submit = async (e?: FormEvent) => {
|
const submit = async (e?: FormEvent) => {
|
||||||
e?.preventDefault();
|
e?.preventDefault();
|
||||||
@@ -60,6 +74,66 @@ export function CardForm({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const insertAtCursor = (snippet: string) => {
|
||||||
|
const ta = textareaRef.current;
|
||||||
|
if (!ta) {
|
||||||
|
setDescription((d) => (d ? d + "\n" + snippet : snippet));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const start = ta.selectionStart ?? description.length;
|
||||||
|
const end = ta.selectionEnd ?? description.length;
|
||||||
|
const before = description.slice(0, start);
|
||||||
|
const after = description.slice(end);
|
||||||
|
const sep = before && !before.endsWith("\n") ? "\n" : "";
|
||||||
|
const next = before + sep + snippet + after;
|
||||||
|
setDescription(next);
|
||||||
|
queueMicrotask(() => {
|
||||||
|
ta.focus();
|
||||||
|
const pos = (before + sep + snippet).length;
|
||||||
|
ta.setSelectionRange(pos, pos);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFiles = async (files: FileList | File[]) => {
|
||||||
|
if (!cardId) {
|
||||||
|
notifications.show({ color: "yellow", message: "Guarda la tarjeta antes de subir archivos." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setUploading(true);
|
||||||
|
try {
|
||||||
|
for (const file of Array.from(files)) {
|
||||||
|
try {
|
||||||
|
const cf = await api.uploadCardFile(cardId, file, "description");
|
||||||
|
insertAtCursor(markdownRef(cf.filename, cf.url, cf.mime.startsWith("image/")));
|
||||||
|
onFileUploaded?.();
|
||||||
|
} catch (e) {
|
||||||
|
notifications.show({ color: "red", message: `${file.name}: ${(e as Error).message}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDrop = (e: DragEvent<HTMLDivElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragOver(false);
|
||||||
|
if (!e.dataTransfer.files || e.dataTransfer.files.length === 0) return;
|
||||||
|
handleFiles(e.dataTransfer.files);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDragOver = (e: DragEvent<HTMLDivElement>) => {
|
||||||
|
if (!cardId) return;
|
||||||
|
if (!e.dataTransfer.types.includes("Files")) return;
|
||||||
|
e.preventDefault();
|
||||||
|
setDragOver(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDragLeave = (e: DragEvent<HTMLDivElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragOver(false);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={submit}>
|
<form onSubmit={submit}>
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
@@ -95,17 +169,48 @@ export function CardForm({
|
|||||||
if (e.key === "Enter") e.preventDefault();
|
if (e.key === "Enter") e.preventDefault();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Textarea
|
<Box
|
||||||
label="Descripcion"
|
onDrop={onDrop}
|
||||||
value={description}
|
onDragOver={onDragOver}
|
||||||
onChange={(e) => setDescription(e.currentTarget.value)}
|
onDragLeave={onDragLeave}
|
||||||
tabIndex={3}
|
style={{
|
||||||
autosize
|
position: "relative",
|
||||||
minRows={3}
|
outline: dragOver ? "2px dashed var(--mantine-color-blue-5)" : undefined,
|
||||||
maxRows={8}
|
outlineOffset: dragOver ? 2 : undefined,
|
||||||
onKeyDown={textareaEnter}
|
borderRadius: 4,
|
||||||
description="Ctrl+Enter para guardar"
|
}}
|
||||||
/>
|
>
|
||||||
|
<Textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
label="Descripcion"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.currentTarget.value)}
|
||||||
|
tabIndex={3}
|
||||||
|
autosize
|
||||||
|
minRows={3}
|
||||||
|
maxRows={8}
|
||||||
|
onKeyDown={textareaEnter}
|
||||||
|
description={cardId ? "Ctrl+Enter para guardar. Arrastra archivos para adjuntar." : "Ctrl+Enter para guardar"}
|
||||||
|
/>
|
||||||
|
{(dragOver || uploading) && (
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
background: "rgba(34,139,230,0.08)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
pointerEvents: "none",
|
||||||
|
borderRadius: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text size="sm" fw={500} c="blue">
|
||||||
|
{uploading ? "Subiendo..." : "Suelta para adjuntar"}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
<Select
|
<Select
|
||||||
label="Asignar a"
|
label="Asignar a"
|
||||||
placeholder="Sin asignar"
|
placeholder="Sin asignar"
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { Anchor, Image, Text } from "@mantine/core";
|
||||||
|
import { Fragment, ReactNode } from "react";
|
||||||
|
|
||||||
|
// Minimal markdown renderer for chat/desc embeds (issue 0128).
|
||||||
|
// Recognises:
|
||||||
|
//  -> <Image>
|
||||||
|
// [name](url) -> <Anchor>name</Anchor>
|
||||||
|
// Everything else is plain text with preserved whitespace.
|
||||||
|
|
||||||
|
interface ImgToken { kind: "img"; alt: string; url: string }
|
||||||
|
interface LinkToken { kind: "link"; label: string; url: string }
|
||||||
|
interface TextToken { kind: "text"; value: string }
|
||||||
|
type Token = ImgToken | LinkToken | TextToken;
|
||||||
|
|
||||||
|
const TOKEN_RE = /(!\[([^\]\n]*)\]\(([^)\s]+)\))|(\[([^\]\n]+)\]\(([^)\s]+)\))/g;
|
||||||
|
|
||||||
|
function tokenize(input: string): Token[] {
|
||||||
|
const out: Token[] = [];
|
||||||
|
let last = 0;
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
TOKEN_RE.lastIndex = 0;
|
||||||
|
while ((m = TOKEN_RE.exec(input)) !== null) {
|
||||||
|
if (m.index > last) {
|
||||||
|
out.push({ kind: "text", value: input.slice(last, m.index) });
|
||||||
|
}
|
||||||
|
if (m[1]) {
|
||||||
|
out.push({ kind: "img", alt: m[2] || "", url: m[3] });
|
||||||
|
} else if (m[4]) {
|
||||||
|
out.push({ kind: "link", label: m[5], url: m[6] });
|
||||||
|
}
|
||||||
|
last = TOKEN_RE.lastIndex;
|
||||||
|
}
|
||||||
|
if (last < input.length) {
|
||||||
|
out.push({ kind: "text", value: input.slice(last) });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
text: string;
|
||||||
|
size?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MessageBody({ text, size = "sm" }: Props): ReactNode {
|
||||||
|
const tokens = tokenize(text);
|
||||||
|
if (tokens.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inlineNodes: ReactNode[] = [];
|
||||||
|
const blocks: ReactNode[] = [];
|
||||||
|
let key = 0;
|
||||||
|
|
||||||
|
const flushInline = () => {
|
||||||
|
if (inlineNodes.length === 0) return;
|
||||||
|
blocks.push(
|
||||||
|
<Text
|
||||||
|
key={`t-${key++}`}
|
||||||
|
size={size}
|
||||||
|
style={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}
|
||||||
|
>
|
||||||
|
{inlineNodes.map((n, i) => (
|
||||||
|
<Fragment key={i}>{n}</Fragment>
|
||||||
|
))}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
inlineNodes.length = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const t of tokens) {
|
||||||
|
if (t.kind === "img") {
|
||||||
|
flushInline();
|
||||||
|
blocks.push(
|
||||||
|
<Anchor key={`i-${key++}`} href={t.url} target="_blank" rel="noopener noreferrer">
|
||||||
|
<Image
|
||||||
|
src={t.url}
|
||||||
|
alt={t.alt}
|
||||||
|
maw={220}
|
||||||
|
mah={220}
|
||||||
|
fit="contain"
|
||||||
|
radius="sm"
|
||||||
|
/>
|
||||||
|
</Anchor>
|
||||||
|
);
|
||||||
|
} else if (t.kind === "link") {
|
||||||
|
inlineNodes.push(
|
||||||
|
<Anchor key={`l-${key++}`} href={t.url} target="_blank" rel="noopener noreferrer">
|
||||||
|
{t.label}
|
||||||
|
</Anchor>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
inlineNodes.push(t.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flushInline();
|
||||||
|
|
||||||
|
return <>{blocks}</>;
|
||||||
|
}
|
||||||
@@ -46,6 +46,18 @@ export interface Card {
|
|||||||
total_locked_ms: number;
|
total_locked_ms: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CardFile {
|
||||||
|
id: string;
|
||||||
|
card_id: string;
|
||||||
|
uploader_id: string;
|
||||||
|
filename: string;
|
||||||
|
mime: string;
|
||||||
|
size: number;
|
||||||
|
source: "upload" | "description" | "chat";
|
||||||
|
url: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
id: string;
|
id: string;
|
||||||
username: string;
|
username: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user