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 {
|
||||
Board,
|
||||
Card,
|
||||
CardFile,
|
||||
CardHistoryResponse,
|
||||
CardMessage,
|
||||
Column,
|
||||
@@ -380,6 +381,42 @@ export function listRequesters(): Promise<string[]> {
|
||||
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> {
|
||||
const qs = new URLSearchParams();
|
||||
if (f.from) qs.set("from", f.from);
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
ActionIcon,
|
||||
Avatar,
|
||||
Box,
|
||||
FileButton,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
@@ -11,26 +12,35 @@ import {
|
||||
Textarea,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { IconSend, IconTrash } from "@tabler/icons-react";
|
||||
import { IconPaperclip, IconSend, IconTrash } from "@tabler/icons-react";
|
||||
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 type { CardMessage, User } from "../types";
|
||||
import { tagColor } from "./colors";
|
||||
import { formatDateTimeShort } from "./format";
|
||||
import { MessageBody } from "./MessageBody";
|
||||
|
||||
interface Props {
|
||||
cardId: string;
|
||||
users: User[];
|
||||
currentUserId?: string;
|
||||
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 [loading, setLoading] = useState(true);
|
||||
const [body, setBody] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const viewportRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
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 (
|
||||
<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
|
||||
viewportRef={viewportRef}
|
||||
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>
|
||||
) : messages.length === 0 ? (
|
||||
<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>
|
||||
) : (
|
||||
<Stack gap={6} p={4}>
|
||||
@@ -138,9 +202,9 @@ export function CardChatPanel({ cardId, users, currentUserId, onMessagesChange }
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
<Text size="sm" style={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}>
|
||||
{m.body}
|
||||
</Text>
|
||||
<Stack gap={4}>
|
||||
<MessageBody text={m.body} />
|
||||
</Stack>
|
||||
</Box>
|
||||
</Group>
|
||||
</Paper>
|
||||
@@ -154,13 +218,29 @@ export function CardChatPanel({ cardId, users, currentUserId, onMessagesChange }
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.currentTarget.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder="Escribe un mensaje (Enter = enviar, Shift+Enter = salto)"
|
||||
placeholder="Escribe un mensaje. Arrastra archivos o usa el clip."
|
||||
autosize
|
||||
minRows={1}
|
||||
maxRows={6}
|
||||
style={{ flex: 1 }}
|
||||
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>
|
||||
<ActionIcon
|
||||
size="lg"
|
||||
@@ -174,6 +254,24 @@ export function CardChatPanel({ cardId, users, currentUserId, onMessagesChange }
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 { useState } from "react";
|
||||
import type { Card, CardMessage, User } from "../types";
|
||||
import { CardChatPanel } from "./CardChatPanel";
|
||||
import { CardFilesPanel } from "./CardFilesPanel";
|
||||
import { CardLinksPanel } from "./CardLinksPanel";
|
||||
import { CardForm, CardFormValues } from "./CardForm";
|
||||
|
||||
@@ -27,12 +28,15 @@ export function CardEditPanel({
|
||||
}: Props) {
|
||||
const [messages, setMessages] = useState<CardMessage[]>([]);
|
||||
const [liveCard, setLiveCard] = useState(card);
|
||||
const [filesRefreshKey, setFilesRefreshKey] = useState(0);
|
||||
|
||||
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 }));
|
||||
await onSubmit(v);
|
||||
};
|
||||
|
||||
const bumpFiles = () => setFilesRefreshKey((k) => k + 1);
|
||||
|
||||
return (
|
||||
<Group align="stretch" gap="md" wrap="nowrap" style={{ minHeight: 460 }}>
|
||||
<Box style={{ flex: "1 1 0", minWidth: 320 }}>
|
||||
@@ -48,6 +52,8 @@ export function CardEditPanel({
|
||||
tags: liveCard.tags || [],
|
||||
}}
|
||||
submitLabel="Guardar"
|
||||
cardId={liveCard.id}
|
||||
onFileUploaded={bumpFiles}
|
||||
onSubmit={wrappedSubmit}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
@@ -58,7 +64,7 @@ export function CardEditPanel({
|
||||
<Tabs.List>
|
||||
<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="files" leftSection={<IconPaperclip size={14} />} disabled>Archivos</Tabs.Tab>
|
||||
<Tabs.Tab value="files" leftSection={<IconPaperclip size={14} />}>Archivos</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
<Box pt="xs" style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
|
||||
<Tabs.Panel value="chat" style={{ flex: 1, minHeight: 0, display: "flex" }}>
|
||||
@@ -68,6 +74,7 @@ export function CardEditPanel({
|
||||
users={users}
|
||||
currentUserId={currentUserId}
|
||||
onMessagesChange={setMessages}
|
||||
onFileUploaded={bumpFiles}
|
||||
/>
|
||||
</Box>
|
||||
</Tabs.Panel>
|
||||
@@ -75,9 +82,7 @@ export function CardEditPanel({
|
||||
<CardLinksPanel card={liveCard} messages={messages} />
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="files">
|
||||
<Text size="sm" c="dimmed" ta="center" p="md">
|
||||
Proximamente: adjuntos de archivos.
|
||||
</Text>
|
||||
<CardFilesPanel cardId={liveCard.id} refreshKey={filesRefreshKey} />
|
||||
</Tabs.Panel>
|
||||
</Box>
|
||||
</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 { FormEvent, KeyboardEvent, useState } from "react";
|
||||
import { Autocomplete, Box, Button, Group, Select, Stack, TagsInput, Text, Textarea } from "@mantine/core";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { DragEvent, FormEvent, KeyboardEvent, useRef, useState } from "react";
|
||||
import * as api from "../api";
|
||||
import type { User } from "../types";
|
||||
|
||||
// CardForm: Solicitante (Autocomplete) intencionadamente NO hace submit en Enter.
|
||||
@@ -21,16 +23,25 @@ interface Props {
|
||||
users?: User[];
|
||||
requesterOptions?: string[];
|
||||
tagOptions?: string[];
|
||||
cardId?: string;
|
||||
onFileUploaded?: () => void;
|
||||
onSubmit: (v: CardFormValues) => Promise<void> | void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
function markdownRef(filename: string, url: string, isImage: boolean): string {
|
||||
const safeName = filename.replace(/]/g, "");
|
||||
return isImage ? `` : `[${safeName}](${url})`;
|
||||
}
|
||||
|
||||
export function CardForm({
|
||||
initial,
|
||||
submitLabel = "Guardar",
|
||||
users = [],
|
||||
requesterOptions = [],
|
||||
tagOptions = [],
|
||||
cardId,
|
||||
onFileUploaded,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: Props) {
|
||||
@@ -39,6 +50,9 @@ export function CardForm({
|
||||
const [description, setDescription] = useState(initial?.description ?? "");
|
||||
const [assigneeId, setAssigneeId] = useState<string | null>(initial?.assignee_id ?? null);
|
||||
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) => {
|
||||
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 (
|
||||
<form onSubmit={submit}>
|
||||
<Stack gap="sm">
|
||||
@@ -95,17 +169,48 @@ export function CardForm({
|
||||
if (e.key === "Enter") e.preventDefault();
|
||||
}}
|
||||
/>
|
||||
<Textarea
|
||||
label="Descripcion"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.currentTarget.value)}
|
||||
tabIndex={3}
|
||||
autosize
|
||||
minRows={3}
|
||||
maxRows={8}
|
||||
onKeyDown={textareaEnter}
|
||||
description="Ctrl+Enter para guardar"
|
||||
/>
|
||||
<Box
|
||||
onDrop={onDrop}
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
style={{
|
||||
position: "relative",
|
||||
outline: dragOver ? "2px dashed var(--mantine-color-blue-5)" : undefined,
|
||||
outlineOffset: dragOver ? 2 : undefined,
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
<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
|
||||
label="Asignar a"
|
||||
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;
|
||||
}
|
||||
|
||||
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 {
|
||||
id: string;
|
||||
username: string;
|
||||
|
||||
Reference in New Issue
Block a user