Commit be1b758d authored by Johnny's avatar Johnny

refactor: simplify memo-metadata components

parent d7284fe8
...@@ -34,12 +34,11 @@ const LocationMarker = (props: MarkerProps) => { ...@@ -34,12 +34,11 @@ const LocationMarker = (props: MarkerProps) => {
// Call the parent onChange function. // Call the parent onChange function.
props.onChange(e.latlng); props.onChange(e.latlng);
}, },
locationfound() {}, locationfound() { },
}); });
useEffect(() => { useEffect(() => {
if (!initializedRef.current) { if (!initializedRef.current) {
map.attributionControl.setPrefix("");
map.locate(); map.locate();
initializedRef.current = true; initializedRef.current = true;
} }
...@@ -247,7 +246,7 @@ const LeafletMap = (props: MapProps) => { ...@@ -247,7 +246,7 @@ const LeafletMap = (props: MapProps) => {
isDark ? "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png" : "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" isDark ? "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png" : "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
} }
/> />
<LocationMarker position={position} readonly={props.readonly} onChange={props.onChange ? props.onChange : () => {}} /> <LocationMarker position={position} readonly={props.readonly} onChange={props.onChange ? props.onChange : () => { }} />
<MapControls position={props.latlng} /> <MapControls position={props.latlng} />
<MapCleanup /> <MapCleanup />
</MapContainer> </MapContainer>
......
import { ChevronDownIcon, ChevronUpIcon, FileIcon, Loader2Icon, XIcon } from "lucide-react";
import type { FC } from "react";
import type { AttachmentItem } from "@/components/memo-metadata/types";
import { cn } from "@/lib/utils";
import { formatFileSize, getFileTypeLabel } from "@/utils/format";
interface AttachmentItemCardProps {
item: AttachmentItem;
onRemove?: () => void;
onMoveUp?: () => void;
onMoveDown?: () => void;
canMoveUp?: boolean;
canMoveDown?: boolean;
className?: string;
}
const AttachmentItemCard: FC<AttachmentItemCardProps> = ({
item,
onRemove,
onMoveUp,
onMoveDown,
canMoveUp = true,
canMoveDown = true,
className,
}) => {
const { category, filename, thumbnailUrl, mimeType, size, isLocal } = item;
const fileTypeLabel = getFileTypeLabel(mimeType);
const fileSizeLabel = size ? formatFileSize(size) : undefined;
return (
<div
className={cn(
"relative flex items-center gap-1.5 px-1.5 py-1 rounded border border-transparent hover:border-border hover:bg-accent/20 transition-all",
className,
)}
>
<div className="flex-shrink-0 w-6 h-6 rounded overflow-hidden bg-muted/40 flex items-center justify-center">
{category === "image" && thumbnailUrl ? (
<img src={thumbnailUrl} alt="" className="w-full h-full object-cover" />
) : (
<FileIcon className="w-3.5 h-3.5 text-muted-foreground" />
)}
</div>
<div className="flex-1 min-w-0 flex flex-col sm:flex-row sm:items-baseline gap-0.5 sm:gap-1.5">
<span className="text-xs font-medium truncate" title={filename}>
{filename}
</span>
<div className="flex items-center gap-1 text-[11px] text-muted-foreground shrink-0">
{isLocal && (
<>
<Loader2Icon className="w-2.5 h-2.5 animate-spin" />
<span className="text-muted-foreground/50"></span>
</>
)}
<span>{fileTypeLabel}</span>
{fileSizeLabel && (
<>
<span className="text-muted-foreground/50 hidden sm:inline"></span>
<span className="hidden sm:inline">{fileSizeLabel}</span>
</>
)}
</div>
</div>
<div className="flex-shrink-0 flex items-center gap-0.5">
{onMoveUp && (
<button
type="button"
onClick={onMoveUp}
disabled={!canMoveUp}
className={cn(
"p-0.5 rounded hover:bg-accent active:bg-accent transition-colors touch-manipulation",
!canMoveUp && "opacity-20 cursor-not-allowed hover:bg-transparent",
)}
title="Move up"
aria-label="Move attachment up"
>
<ChevronUpIcon className="w-3 h-3 text-muted-foreground" />
</button>
)}
{onMoveDown && (
<button
type="button"
onClick={onMoveDown}
disabled={!canMoveDown}
className={cn(
"p-0.5 rounded hover:bg-accent active:bg-accent transition-colors touch-manipulation",
!canMoveDown && "opacity-20 cursor-not-allowed hover:bg-transparent",
)}
title="Move down"
aria-label="Move attachment down"
>
<ChevronDownIcon className="w-3 h-3 text-muted-foreground" />
</button>
)}
{onRemove && (
<button
type="button"
onClick={onRemove}
className="p-0.5 rounded hover:bg-destructive/10 active:bg-destructive/10 transition-colors ml-0.5 touch-manipulation"
title="Remove"
aria-label="Remove attachment"
>
<XIcon className="w-3 h-3 text-muted-foreground hover:text-destructive" />
</button>
)}
</div>
</div>
);
};
export default AttachmentItemCard;
import { PaperclipIcon } from "lucide-react";
import type { FC } from "react";
import type { LocalFile } from "@/components/memo-metadata/types";
import { toAttachmentItems } from "@/components/memo-metadata/types";
import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb";
import AttachmentItemCard from "./AttachmentItemCard";
interface AttachmentListV2Props {
attachments: Attachment[];
localFiles?: LocalFile[];
onAttachmentsChange?: (attachments: Attachment[]) => void;
onRemoveLocalFile?: (previewUrl: string) => void;
}
const AttachmentListV2: FC<AttachmentListV2Props> = ({ attachments, localFiles = [], onAttachmentsChange, onRemoveLocalFile }) => {
if (attachments.length === 0 && localFiles.length === 0) {
return null;
}
const items = toAttachmentItems(attachments, localFiles);
const handleMoveUp = (index: number) => {
if (index === 0 || !onAttachmentsChange) return;
const newAttachments = [...attachments];
[newAttachments[index - 1], newAttachments[index]] = [newAttachments[index], newAttachments[index - 1]];
onAttachmentsChange(newAttachments);
};
const handleMoveDown = (index: number) => {
if (index === attachments.length - 1 || !onAttachmentsChange) return;
const newAttachments = [...attachments];
[newAttachments[index], newAttachments[index + 1]] = [newAttachments[index + 1], newAttachments[index]];
onAttachmentsChange(newAttachments);
};
const handleRemoveAttachment = (name: string) => {
if (onAttachmentsChange) {
onAttachmentsChange(attachments.filter((attachment) => attachment.name !== name));
}
};
const handleRemoveItem = (item: (typeof items)[0]) => {
if (item.isLocal) {
onRemoveLocalFile?.(item.id);
} else {
handleRemoveAttachment(item.id);
}
};
return (
<div className="w-full rounded-lg border border-border bg-muted/20 overflow-hidden">
<div className="flex items-center gap-1.5 px-2 py-1.5 border-b border-border bg-muted/30">
<PaperclipIcon className="w-3.5 h-3.5 text-muted-foreground" />
<span className="text-xs font-medium text-muted-foreground">Attachments ({items.length})</span>
</div>
<div className="p-1 sm:p-1.5 flex flex-col gap-0.5">
{items.map((item) => {
const isLocalFile = item.isLocal;
const attachmentIndex = isLocalFile ? -1 : attachments.findIndex((a) => a.name === item.id);
return (
<AttachmentItemCard
key={item.id}
item={item}
onRemove={() => handleRemoveItem(item)}
onMoveUp={!isLocalFile ? () => handleMoveUp(attachmentIndex) : undefined}
onMoveDown={!isLocalFile ? () => handleMoveDown(attachmentIndex) : undefined}
canMoveUp={!isLocalFile && attachmentIndex > 0}
canMoveDown={!isLocalFile && attachmentIndex < attachments.length - 1}
/>
);
})}
</div>
</div>
);
};
export default AttachmentListV2;
import type { FC } from "react"; import type { FC } from "react";
import { AttachmentList, LocationDisplay, RelationList } from "@/components/memo-metadata";
import { useEditorContext } from "../state"; import { useEditorContext } from "../state";
import type { EditorMetadataProps } from "../types"; import type { EditorMetadataProps } from "../types";
import AttachmentListV2 from "./AttachmentListV2";
import LocationDisplayV2 from "./LocationDisplayV2";
import RelationListV2 from "./RelationListV2";
export const EditorMetadata: FC<EditorMetadataProps> = () => { export const EditorMetadata: FC<EditorMetadataProps> = () => {
const { state, actions, dispatch } = useEditorContext(); const { state, actions, dispatch } = useEditorContext();
return ( return (
<div className="w-full flex flex-col gap-2"> <div className="w-full flex flex-col gap-2">
{state.metadata.location && ( <AttachmentListV2
<LocationDisplay
mode="edit"
location={state.metadata.location}
onRemove={() => dispatch(actions.setMetadata({ location: undefined }))}
/>
)}
<AttachmentList
mode="edit"
attachments={state.metadata.attachments} attachments={state.metadata.attachments}
localFiles={state.localFiles} localFiles={state.localFiles}
onAttachmentsChange={(attachments) => dispatch(actions.setMetadata({ attachments }))} onAttachmentsChange={(attachments) => dispatch(actions.setMetadata({ attachments }))}
onRemoveLocalFile={(previewUrl) => dispatch(actions.removeLocalFile(previewUrl))} onRemoveLocalFile={(previewUrl) => dispatch(actions.removeLocalFile(previewUrl))}
/> />
<RelationList <RelationListV2
mode="edit"
relations={state.metadata.relations} relations={state.metadata.relations}
currentMemoName=""
onRelationsChange={(relations) => dispatch(actions.setMetadata({ relations }))} onRelationsChange={(relations) => dispatch(actions.setMetadata({ relations }))}
/> />
{state.metadata.location && (
<LocationDisplayV2 location={state.metadata.location} onRemove={() => dispatch(actions.setMetadata({ location: undefined }))} />
)}
</div> </div>
); );
}; };
import { MapPinIcon, XIcon } from "lucide-react";
import type { FC } from "react";
import { cn } from "@/lib/utils";
import type { Location } from "@/types/proto/api/v1/memo_service_pb";
interface LocationDisplayV2Props {
location: Location;
onRemove?: () => void;
className?: string;
}
const LocationDisplayV2: FC<LocationDisplayV2Props> = ({ location, onRemove, className }) => {
const displayText = location.placeholder || `${location.latitude.toFixed(6)}, ${location.longitude.toFixed(6)}`;
return (
<div
className={cn(
"relative flex items-center gap-1.5 px-1.5 py-1 rounded border border-border bg-background hover:bg-accent/20 transition-all w-full",
className,
)}
>
<MapPinIcon className="w-3.5 h-3.5 shrink-0 text-muted-foreground" />
<div className="flex items-center gap-1.5 min-w-0 flex-1">
<span className="text-xs font-medium truncate" title={displayText}>
{displayText}
</span>
<span className="text-[11px] text-muted-foreground shrink-0 hidden sm:inline">
{location.latitude.toFixed(4)}°, {location.longitude.toFixed(4)}°
</span>
</div>
{onRemove && (
<button
type="button"
onClick={onRemove}
className="p-0.5 rounded hover:bg-destructive/10 active:bg-destructive/10 transition-colors touch-manipulation shrink-0 ml-auto"
title="Remove"
aria-label="Remove location"
>
<XIcon className="w-3 h-3 text-muted-foreground hover:text-destructive" />
</button>
)}
</div>
);
};
export default LocationDisplayV2;
import { LinkIcon, XIcon } from "lucide-react";
import type { FC } from "react";
import { Link } from "react-router-dom";
import { extractMemoIdFromName } from "@/helpers/resource-names";
import { cn } from "@/lib/utils";
import type { MemoRelation_Memo } from "@/types/proto/api/v1/memo_service_pb";
interface RelationItemCardProps {
memo: MemoRelation_Memo;
onRemove?: () => void;
parentPage?: string;
className?: string;
}
const RelationItemCard: FC<RelationItemCardProps> = ({ memo, onRemove, parentPage, className }) => {
const memoId = extractMemoIdFromName(memo.name);
if (onRemove) {
return (
<div
className={cn(
"relative flex items-center gap-1.5 px-1.5 py-1 rounded border border-transparent hover:border-border hover:bg-accent/20 transition-all",
className,
)}
>
<LinkIcon className="w-3.5 h-3.5 shrink-0 text-muted-foreground" />
<span className="text-xs font-medium truncate flex-1" title={memo.snippet}>
{memo.snippet}
</span>
<div className="flex-shrink-0 flex items-center gap-0.5">
<button
type="button"
onClick={onRemove}
className="p-0.5 rounded hover:bg-destructive/10 active:bg-destructive/10 transition-colors touch-manipulation"
title="Remove"
aria-label="Remove relation"
>
<XIcon className="w-3 h-3 text-muted-foreground hover:text-destructive" />
</button>
</div>
</div>
);
}
return (
<Link
className={cn(
"relative flex items-center gap-1.5 px-1.5 py-1 rounded border border-transparent hover:border-border hover:bg-accent/20 transition-all",
className,
)}
to={`/${memo.name}`}
viewTransition
state={{ from: parentPage }}
>
<span className="text-[10px] font-mono px-1 py-0.5 rounded bg-muted/50 text-muted-foreground shrink-0">{memoId.slice(0, 6)}</span>
<span className="text-xs truncate flex-1" title={memo.snippet}>
{memo.snippet}
</span>
</Link>
);
};
export default RelationItemCard;
import { create } from "@bufbuild/protobuf";
import { LinkIcon } from "lucide-react";
import type { FC } from "react";
import { useEffect, useState } from "react";
import { memoServiceClient } from "@/connect";
import type { Memo, MemoRelation } from "@/types/proto/api/v1/memo_service_pb";
import { MemoRelation_MemoSchema } from "@/types/proto/api/v1/memo_service_pb";
import RelationItemCard from "./RelationItemCard";
interface RelationListV2Props {
relations: MemoRelation[];
onRelationsChange?: (relations: MemoRelation[]) => void;
}
const RelationListV2: FC<RelationListV2Props> = ({ relations, onRelationsChange }) => {
const [referencingMemos, setReferencingMemos] = useState<Memo[]>([]);
useEffect(() => {
(async () => {
if (relations.length > 0) {
const requests = relations.map(async (relation) => {
return await memoServiceClient.getMemo({ name: relation.relatedMemo!.name });
});
const list = await Promise.all(requests);
setReferencingMemos(list);
} else {
setReferencingMemos([]);
}
})();
}, [relations]);
const handleDeleteRelation = (memoName: string) => {
if (onRelationsChange) {
onRelationsChange(relations.filter((relation) => relation.relatedMemo?.name !== memoName));
}
};
if (referencingMemos.length === 0) {
return null;
}
return (
<div className="w-full rounded-lg border border-border bg-muted/20 overflow-hidden">
<div className="flex items-center gap-1.5 px-2 py-1.5 border-b border-border bg-muted/30">
<LinkIcon className="w-3.5 h-3.5 text-muted-foreground" />
<span className="text-xs font-medium text-muted-foreground">Relations ({referencingMemos.length})</span>
</div>
<div className="p-1 sm:p-1.5 flex flex-col gap-0.5">
{referencingMemos.map((memo) => (
<RelationItemCard
key={memo.name}
memo={create(MemoRelation_MemoSchema, { name: memo.name, snippet: memo.snippet })}
onRemove={() => handleDeleteRelation(memo.name)}
/>
))}
</div>
</div>
);
};
export default RelationListV2;
// UI components for MemoEditor // UI components for MemoEditor
export { default as AttachmentItemCard } from "./AttachmentItemCard";
export { default as AttachmentListV2 } from "./AttachmentListV2";
export * from "./EditorContent"; export * from "./EditorContent";
export * from "./EditorMetadata"; export * from "./EditorMetadata";
export * from "./EditorToolbar"; export * from "./EditorToolbar";
export { FocusModeExitButton, FocusModeOverlay } from "./FocusModeOverlay"; export { FocusModeExitButton, FocusModeOverlay } from "./FocusModeOverlay";
export { LinkMemoDialog } from "./LinkMemoDialog"; export { LinkMemoDialog } from "./LinkMemoDialog";
export { LocationDialog } from "./LocationDialog"; export { LocationDialog } from "./LocationDialog";
export { default as LocationDisplayV2 } from "./LocationDisplayV2";
export { default as RelationItemCard } from "./RelationItemCard";
export { default as RelationListV2 } from "./RelationListV2";
...@@ -127,7 +127,7 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({ ...@@ -127,7 +127,7 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
*/} */}
<div <div
className={cn( className={cn(
"group relative w-full flex flex-col justify-between items-start bg-card px-4 pt-3 pb-1 rounded-lg border border-border", "group relative w-full flex flex-col justify-between items-start bg-card px-4 pt-3 pb-1 rounded-lg border border-border gap-2",
FOCUS_MODE_STYLES.transition, FOCUS_MODE_STYLES.transition,
state.ui.isFocusMode && cn(FOCUS_MODE_STYLES.container.base, FOCUS_MODE_STYLES.container.spacing), state.ui.isFocusMode && cn(FOCUS_MODE_STYLES.container.base, FOCUS_MODE_STYLES.container.spacing),
className, className,
......
...@@ -29,9 +29,9 @@ const MemoBody: React.FC<MemoBodyProps> = ({ compact, onContentClick, onContentD ...@@ -29,9 +29,9 @@ const MemoBody: React.FC<MemoBodyProps> = ({ compact, onContentClick, onContentD
onDoubleClick={onContentDoubleClick} onDoubleClick={onContentDoubleClick}
compact={memo.pinned ? false : compact} // Always show full content when pinned compact={memo.pinned ? false : compact} // Always show full content when pinned
/> />
{memo.location && <LocationDisplay mode="view" location={memo.location} />} <AttachmentList attachments={memo.attachments} />
<AttachmentList mode="view" attachments={memo.attachments} /> <RelationList relations={referencedMemos} currentMemoName={memo.name} parentPage={parentPage} />
<RelationList mode="view" relations={referencedMemos} currentMemoName={memo.name} parentPage={parentPage} /> {memo.location && <LocationDisplay location={memo.location} />}
<MemoReactionListView memo={memo} reactions={memo.reactions} /> <MemoReactionListView memo={memo} reactions={memo.reactions} />
</div> </div>
......
import { FileIcon, XIcon } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { AttachmentItem, DisplayMode } from "./types"; import type { AttachmentItem } from "./types";
interface AttachmentCardProps { interface AttachmentCardProps {
item: AttachmentItem; item: AttachmentItem;
mode: DisplayMode; mode: "view";
onRemove?: () => void;
onClick?: () => void; onClick?: () => void;
className?: string; className?: string;
showThumbnail?: boolean;
} }
const AttachmentCard = ({ item, mode, onRemove, onClick, className, showThumbnail = true }: AttachmentCardProps) => { const AttachmentCard = ({ item, onClick, className }: AttachmentCardProps) => {
const { category, filename, thumbnailUrl, sourceUrl } = item; const { category, filename, sourceUrl } = item;
const isMedia = category === "image" || category === "video";
// Editor mode - compact badge style with optional thumbnail if (category === "image") {
if (mode === "edit") {
return ( return (
<div <img
className={cn( src={sourceUrl}
"relative inline-flex items-center gap-1.5 px-2 h-7 rounded-md border text-secondary-foreground text-xs transition-colors", alt={filename}
"border-border bg-background hover:bg-accent", className={cn("w-full h-full object-cover rounded-lg cursor-pointer", className)}
className, onClick={onClick}
)} loading="lazy"
> />
{showThumbnail && category === "image" && thumbnailUrl ? (
<img src={thumbnailUrl} alt={filename} className="w-5 h-5 shrink-0 object-cover rounded" />
) : (
<FileIcon className="w-3.5 h-3.5 shrink-0 text-muted-foreground" />
)}
<span className="truncate max-w-40">{filename}</span>
{onRemove && (
<button
type="button"
className="shrink-0 rounded hover:bg-accent transition-colors p-0.5"
onPointerDown={(e) => {
e.stopPropagation();
}}
onMouseDown={(e) => {
e.stopPropagation();
}}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onRemove();
}}
>
<XIcon className="w-3 h-3 text-muted-foreground hover:text-foreground" />
</button>
)}
</div>
); );
} }
// View mode - specialized rendering for media if (category === "video") {
if (isMedia) { return <video src={sourceUrl} className={cn("w-full h-full object-cover rounded-lg", className)} controls preload="metadata" />;
if (category === "image") {
return (
<img
className={cn("cursor-pointer h-full w-auto rounded-lg border border-border/60 object-contain transition-colors", className)}
src={thumbnailUrl}
onError={(e) => {
const target = e.target as HTMLImageElement;
// Fallback to source URL if thumbnail fails
if (target.src.includes("?thumbnail=true")) {
target.src = sourceUrl;
}
}}
onClick={onClick}
decoding="async"
loading="lazy"
alt={filename}
/>
);
} else if (category === "video") {
return (
<video
className={cn(
"cursor-pointer h-full w-auto rounded-lg border border-border/60 object-contain bg-muted transition-colors",
className,
)}
preload="metadata"
crossOrigin="anonymous"
src={sourceUrl}
controls
/>
);
}
} }
// View mode - non-media files (will be handled by parent component for proper file card display)
return null; return null;
}; };
......
import { closestCenter, DndContext, type DragEndEvent, MouseSensor, TouchSensor, useSensor, useSensors } from "@dnd-kit/core";
import { arrayMove, SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { useState } from "react"; import { useState } from "react";
import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb"; import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb";
import { getAttachmentType, getAttachmentUrl } from "@/utils/attachment"; import { getAttachmentType, getAttachmentUrl } from "@/utils/attachment";
import MemoAttachment from "../MemoAttachment"; import MemoAttachment from "../MemoAttachment";
import PreviewImageDialog from "../PreviewImageDialog"; import PreviewImageDialog from "../PreviewImageDialog";
import AttachmentCard from "./AttachmentCard"; import AttachmentCard from "./AttachmentCard";
import SortableItem from "./SortableItem";
import type { AttachmentItem, BaseMetadataProps, LocalFile } from "./types";
import { separateMediaAndDocs, toAttachmentItems } from "./types"; import { separateMediaAndDocs, toAttachmentItems } from "./types";
interface AttachmentListProps extends BaseMetadataProps { interface AttachmentListProps {
attachments: Attachment[]; attachments: Attachment[];
onAttachmentsChange?: (attachments: Attachment[]) => void;
localFiles?: LocalFile[];
onRemoveLocalFile?: (previewUrl: string) => void;
} }
const AttachmentList = ({ attachments, mode, onAttachmentsChange, localFiles = [], onRemoveLocalFile }: AttachmentListProps) => { const AttachmentList = ({ attachments }: AttachmentListProps) => {
const sensors = useSensors(useSensor(MouseSensor), useSensor(TouchSensor));
const [previewImage, setPreviewImage] = useState<{ open: boolean; urls: string[]; index: number }>({ const [previewImage, setPreviewImage] = useState<{ open: boolean; urls: string[]; index: number }>({
open: false, open: false,
urls: [], urls: [],
index: 0, index: 0,
}); });
const handleDeleteAttachment = (name: string) => {
if (onAttachmentsChange) {
onAttachmentsChange(attachments.filter((attachment) => attachment.name !== name));
}
};
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id && onAttachmentsChange) {
const oldIndex = attachments.findIndex((attachment) => attachment.name === active.id);
const newIndex = attachments.findIndex((attachment) => attachment.name === over.id);
onAttachmentsChange(arrayMove(attachments, oldIndex, newIndex));
}
};
const handleImageClick = (imgUrl: string, mediaAttachments: Attachment[]) => { const handleImageClick = (imgUrl: string, mediaAttachments: Attachment[]) => {
const imgUrls = mediaAttachments const imgUrls = mediaAttachments
.filter((attachment) => getAttachmentType(attachment) === "image/*") .filter((attachment) => getAttachmentType(attachment) === "image/*")
...@@ -49,56 +25,15 @@ const AttachmentList = ({ attachments, mode, onAttachmentsChange, localFiles = [ ...@@ -49,56 +25,15 @@ const AttachmentList = ({ attachments, mode, onAttachmentsChange, localFiles = [
setPreviewImage({ open: true, urls: imgUrls, index }); setPreviewImage({ open: true, urls: imgUrls, index });
}; };
// Editor mode: Display all items as compact badges with drag-and-drop
if (mode === "edit") {
if (attachments.length === 0 && localFiles.length === 0) {
return null;
}
const items = toAttachmentItems(attachments, localFiles);
// Only uploaded attachments support reordering (stable server IDs)
const sortableIds = attachments.map((a) => a.name);
const handleRemoveItem = (item: AttachmentItem) => {
if (item.isLocal) {
onRemoveLocalFile?.(item.id);
} else {
handleDeleteAttachment(item.id);
}
};
return (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={sortableIds} strategy={verticalListSortingStrategy}>
<div className="w-full flex flex-row justify-start flex-wrap gap-2 max-h-[50vh] overflow-y-auto">
{items.map((item) => (
<div key={item.id}>
{/* Uploaded items are wrapped in SortableItem for drag-and-drop */}
{!item.isLocal ? (
<SortableItem id={item.id} className="flex items-center gap-1.5 min-w-0">
<AttachmentCard item={item} mode="edit" onRemove={() => handleRemoveItem(item)} showThumbnail />
</SortableItem>
) : (
/* Local items render directly without sorting capability */
<div className="flex items-center gap-1.5 min-w-0">
<AttachmentCard item={item} mode="edit" onRemove={() => handleRemoveItem(item)} showThumbnail />
</div>
)}
</div>
))}
</div>
</SortableContext>
</DndContext>
);
}
// View mode: Split items into media gallery and document list
const items = toAttachmentItems(attachments, []); const items = toAttachmentItems(attachments, []);
const { media: mediaItems, docs: docItems } = separateMediaAndDocs(items); const { media: mediaItems, docs: docItems } = separateMediaAndDocs(items);
if (attachments.length === 0) {
return null;
}
return ( return (
<> <>
{/* Media Gallery */}
{mediaItems.length > 0 && ( {mediaItems.length > 0 && (
<div className="w-full flex flex-row justify-start overflow-auto gap-2"> <div className="w-full flex flex-row justify-start overflow-auto gap-2">
{mediaItems.map((item) => ( {mediaItems.map((item) => (
...@@ -116,18 +51,15 @@ const AttachmentList = ({ attachments, mode, onAttachmentsChange, localFiles = [ ...@@ -116,18 +51,15 @@ const AttachmentList = ({ attachments, mode, onAttachmentsChange, localFiles = [
</div> </div>
)} )}
{/* Document Files */}
{docItems.length > 0 && ( {docItems.length > 0 && (
<div className="w-full flex flex-row justify-start overflow-auto gap-2"> <div className="w-full flex flex-row justify-start overflow-auto gap-2">
{docItems.map((item) => { {docItems.map((item) => {
// Find original attachment for MemoAttachment component
const attachment = attachments.find((a) => a.name === item.id); const attachment = attachments.find((a) => a.name === item.id);
return attachment ? <MemoAttachment key={item.id} attachment={attachment} /> : null; return attachment ? <MemoAttachment key={item.id} attachment={attachment} /> : null;
})} })}
</div> </div>
)} )}
{/* Image Preview Dialog */}
<PreviewImageDialog <PreviewImageDialog
open={previewImage.open} open={previewImage.open}
onOpenChange={(open) => setPreviewImage((prev) => ({ ...prev, open }))} onOpenChange={(open) => setPreviewImage((prev) => ({ ...prev, open }))}
......
import { LatLng } from "leaflet"; import { LatLng } from "leaflet";
import { MapPinIcon, XIcon } from "lucide-react"; import { MapPinIcon } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Location } from "@/types/proto/api/v1/memo_service_pb"; import type { Location } from "@/types/proto/api/v1/memo_service_pb";
import LeafletMap from "../LeafletMap"; import LeafletMap from "../LeafletMap";
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
import { BaseMetadataProps } from "./types";
interface LocationDisplayProps extends BaseMetadataProps { interface LocationDisplayProps {
location?: Location; location?: Location;
onRemove?: () => void; className?: string;
} }
const LocationDisplay = ({ location, mode, onRemove, className }: LocationDisplayProps) => { const LocationDisplay = ({ location, className }: LocationDisplayProps) => {
const [popoverOpen, setPopoverOpen] = useState<boolean>(false); const [popoverOpen, setPopoverOpen] = useState<boolean>(false);
if (!location) { if (!location) {
...@@ -26,12 +25,11 @@ const LocationDisplay = ({ location, mode, onRemove, className }: LocationDispla ...@@ -26,12 +25,11 @@ const LocationDisplay = ({ location, mode, onRemove, className }: LocationDispla
<PopoverTrigger asChild> <PopoverTrigger asChild>
<div <div
className={cn( className={cn(
"w-auto max-w-full flex flex-row gap-2", "w-auto max-w-full flex flex-row gap-2 cursor-pointer",
"relative inline-flex items-center gap-1.5 px-2 h-7 rounded-md border border-border bg-background hover:bg-accent text-secondary-foreground text-xs transition-colors", "relative inline-flex items-center gap-1.5 px-2 h-7 rounded-md border border-border bg-background hover:bg-accent text-secondary-foreground text-xs transition-colors",
mode === "view" && "cursor-pointer",
className, className,
)} )}
onClick={mode === "view" ? () => setPopoverOpen(true) : undefined} onClick={() => setPopoverOpen(true)}
> >
<span className="shrink-0 text-muted-foreground"> <span className="shrink-0 text-muted-foreground">
<MapPinIcon className="w-3.5 h-3.5" /> <MapPinIcon className="w-3.5 h-3.5" />
...@@ -40,24 +38,6 @@ const LocationDisplay = ({ location, mode, onRemove, className }: LocationDispla ...@@ -40,24 +38,6 @@ const LocationDisplay = ({ location, mode, onRemove, className }: LocationDispla
[{location.latitude.toFixed(2)}°, {location.longitude.toFixed(2)}°] [{location.latitude.toFixed(2)}°, {location.longitude.toFixed(2)}°]
</span> </span>
<span className="text-nowrap truncate">{displayText}</span> <span className="text-nowrap truncate">{displayText}</span>
{onRemove && (
<button
className="shrink-0 rounded hover:bg-accent transition-colors p-0.5"
onPointerDown={(e) => {
e.stopPropagation();
}}
onMouseDown={(e) => {
e.stopPropagation();
}}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onRemove();
}}
>
<XIcon className="w-3 h-3 text-muted-foreground hover:text-foreground" />
</button>
)}
</div> </div>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent align="start"> <PopoverContent align="start">
......
import { LinkIcon, XIcon } from "lucide-react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { extractMemoIdFromName } from "@/helpers/resource-names"; import { extractMemoIdFromName } from "@/helpers/resource-names";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { MemoRelation_Memo } from "@/types/proto/api/v1/memo_service_pb"; import type { MemoRelation_Memo } from "@/types/proto/api/v1/memo_service_pb";
import { DisplayMode } from "./types";
interface RelationCardProps { interface RelationCardProps {
memo: MemoRelation_Memo; memo: MemoRelation_Memo;
mode: DisplayMode;
onRemove?: () => void;
parentPage?: string; parentPage?: string;
className?: string; className?: string;
} }
const RelationCard = ({ memo, mode, onRemove, parentPage, className }: RelationCardProps) => { const RelationCard = ({ memo, parentPage, className }: RelationCardProps) => {
const memoId = extractMemoIdFromName(memo.name); const memoId = extractMemoIdFromName(memo.name);
// Editor mode: Badge with remove
if (mode === "edit") {
return (
<div
className={cn(
"relative inline-flex items-center gap-1.5 px-2 h-7 rounded-md border border-border bg-background text-secondary-foreground text-xs transition-colors hover:bg-accent",
className,
)}
>
<LinkIcon className="w-3.5 h-3.5 shrink-0 text-muted-foreground" />
<span className="truncate max-w-[160px]">{memo.snippet}</span>
{onRemove && (
<button
className="shrink-0 rounded hover:bg-accent transition-colors p-0.5"
onPointerDown={(e) => {
e.stopPropagation();
}}
onMouseDown={(e) => {
e.stopPropagation();
}}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onRemove();
}}
>
<XIcon className="w-3 h-3 text-muted-foreground hover:text-foreground" />
</button>
)}
</div>
);
}
// View mode: Navigable link with ID and snippet
return ( return (
<Link <Link
className={cn( className={cn(
...@@ -58,9 +20,7 @@ const RelationCard = ({ memo, mode, onRemove, parentPage, className }: RelationC ...@@ -58,9 +20,7 @@ const RelationCard = ({ memo, mode, onRemove, parentPage, className }: RelationC
)} )}
to={`/${memo.name}`} to={`/${memo.name}`}
viewTransition viewTransition
state={{ state={{ from: parentPage }}
from: parentPage,
}}
> >
<span className="text-[10px] opacity-60 leading-4 border border-border font-mono px-1 rounded-full mr-1">{memoId.slice(0, 6)}</span> <span className="text-[10px] opacity-60 leading-4 border border-border font-mono px-1 rounded-full mr-1">{memoId.slice(0, 6)}</span>
<span className="truncate">{memo.snippet}</span> <span className="truncate">{memo.snippet}</span>
......
import { create } from "@bufbuild/protobuf";
import { LinkIcon, MilestoneIcon } from "lucide-react"; import { LinkIcon, MilestoneIcon } from "lucide-react";
import { useEffect, useState } from "react"; import { useState } from "react";
import { memoServiceClient } from "@/connect";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Memo, MemoRelation, MemoRelation_MemoSchema, MemoRelation_Type } from "@/types/proto/api/v1/memo_service_pb"; import type { MemoRelation } from "@/types/proto/api/v1/memo_service_pb";
import { MemoRelation_Type } from "@/types/proto/api/v1/memo_service_pb";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
import MetadataCard from "./MetadataCard"; import MetadataCard from "./MetadataCard";
import RelationCard from "./RelationCard"; import RelationCard from "./RelationCard";
import { BaseMetadataProps } from "./types";
interface RelationListProps extends BaseMetadataProps { interface RelationListProps {
relations: MemoRelation[]; relations: MemoRelation[];
currentMemoName?: string; currentMemoName?: string;
onRelationsChange?: (relations: MemoRelation[]) => void;
parentPage?: string; parentPage?: string;
className?: string;
} }
function RelationList({ relations, currentMemoName, mode, onRelationsChange, parentPage, className }: RelationListProps) { function RelationList({ relations, currentMemoName, parentPage, className }: RelationListProps) {
const t = useTranslate(); const t = useTranslate();
const [referencingMemos, setReferencingMemos] = useState<Memo[]>([]);
const [selectedTab, setSelectedTab] = useState<"referencing" | "referenced">("referencing"); const [selectedTab, setSelectedTab] = useState<"referencing" | "referenced">("referencing");
// Get referencing and referenced relations
const referencingRelations = relations.filter( const referencingRelations = relations.filter(
(relation) => (relation) =>
relation.type === MemoRelation_Type.REFERENCE && relation.type === MemoRelation_Type.REFERENCE &&
(mode === "edit" || relation.memo?.name === currentMemoName) && relation.memo?.name === currentMemoName &&
relation.relatedMemo?.name !== currentMemoName, relation.relatedMemo?.name !== currentMemoName,
); );
...@@ -36,60 +32,14 @@ function RelationList({ relations, currentMemoName, mode, onRelationsChange, par ...@@ -36,60 +32,14 @@ function RelationList({ relations, currentMemoName, mode, onRelationsChange, par
relation.relatedMemo?.name === currentMemoName, relation.relatedMemo?.name === currentMemoName,
); );
// Fetch full memo details for editor mode
useEffect(() => {
if (mode === "edit") {
(async () => {
if (referencingRelations.length > 0) {
const requests = referencingRelations.map(async (relation) => {
return await memoServiceClient.getMemo({ name: relation.relatedMemo!.name });
});
const list = await Promise.all(requests);
setReferencingMemos(list);
} else {
setReferencingMemos([]);
}
})();
}
}, [mode, relations]);
const handleDeleteRelation = (memoName: string) => {
if (onRelationsChange) {
onRelationsChange(relations.filter((relation) => relation.relatedMemo?.name !== memoName));
}
};
// Editor mode: Simple badge list
if (mode === "edit") {
if (referencingMemos.length === 0) {
return null;
}
return (
<div className="w-full flex flex-row gap-2 flex-wrap">
{referencingMemos.map((memo) => (
<RelationCard
key={memo.name}
memo={create(MemoRelation_MemoSchema, { name: memo.name, snippet: memo.snippet })}
mode="edit"
onRemove={() => handleDeleteRelation(memo.name)}
/>
))}
</div>
);
}
// View mode: Tabbed card with bidirectional relations
if (referencingRelations.length === 0 && referencedRelations.length === 0) { if (referencingRelations.length === 0 && referencedRelations.length === 0) {
return null; return null;
} }
// Auto-select tab based on which has content
const activeTab = referencingRelations.length === 0 ? "referenced" : selectedTab; const activeTab = referencingRelations.length === 0 ? "referenced" : selectedTab;
return ( return (
<MetadataCard className={className}> <MetadataCard className={className}>
{/* Tabs */}
<div className="w-full flex flex-row justify-start items-center mb-1 gap-3 opacity-60"> <div className="w-full flex flex-row justify-start items-center mb-1 gap-3 opacity-60">
{referencingRelations.length > 0 && ( {referencingRelations.length > 0 && (
<button <button
...@@ -119,20 +69,18 @@ function RelationList({ relations, currentMemoName, mode, onRelationsChange, par ...@@ -119,20 +69,18 @@ function RelationList({ relations, currentMemoName, mode, onRelationsChange, par
)} )}
</div> </div>
{/* Referencing List */}
{activeTab === "referencing" && referencingRelations.length > 0 && ( {activeTab === "referencing" && referencingRelations.length > 0 && (
<div className="w-full flex flex-col justify-start items-start"> <div className="w-full flex flex-col justify-start items-start">
{referencingRelations.map((relation) => ( {referencingRelations.map((relation) => (
<RelationCard key={relation.relatedMemo!.name} memo={relation.relatedMemo!} mode="view" parentPage={parentPage} /> <RelationCard key={relation.relatedMemo!.name} memo={relation.relatedMemo!} parentPage={parentPage} />
))} ))}
</div> </div>
)} )}
{/* Referenced List */}
{activeTab === "referenced" && referencedRelations.length > 0 && ( {activeTab === "referenced" && referencedRelations.length > 0 && (
<div className="w-full flex flex-col justify-start items-start"> <div className="w-full flex flex-col justify-start items-start">
{referencedRelations.map((relation) => ( {referencedRelations.map((relation) => (
<RelationCard key={relation.memo!.name} memo={relation.memo!} mode="view" parentPage={parentPage} /> <RelationCard key={relation.memo!.name} memo={relation.memo!} parentPage={parentPage} />
))} ))}
</div> </div>
)} )}
......
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
interface Props {
id: string;
className: string;
children: React.ReactNode;
}
const SortableItem: React.FC<Props> = ({ id, className, children }: Props) => {
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<div ref={setNodeRef} style={style} {...attributes} {...listeners} className={className}>
{children}
</div>
);
};
export default SortableItem;
export { default as AttachmentCard } from "./AttachmentCard";
export { default as AttachmentList } from "./AttachmentList"; export { default as AttachmentList } from "./AttachmentList";
export { default as LocationDisplay } from "./LocationDisplay"; export { default as LocationDisplay } from "./LocationDisplay";
...@@ -8,5 +7,5 @@ export { default as RelationCard } from "./RelationCard"; ...@@ -8,5 +7,5 @@ export { default as RelationCard } from "./RelationCard";
export { default as RelationList } from "./RelationList"; export { default as RelationList } from "./RelationList";
// Types // Types
export type { AttachmentItem, BaseMetadataProps, DisplayMode, FileCategory, LocalFile } from "./types"; export type { AttachmentItem, FileCategory, LocalFile } from "./types";
export { attachmentToItem, fileToItem, filterByCategory, separateMediaAndDocs, toAttachmentItems } from "./types"; export { attachmentToItem, fileToItem, filterByCategory, separateMediaAndDocs, toAttachmentItems } from "./types";
export function formatFileSize(bytes: number): string {
if (bytes === 0) return "0 B";
if (bytes < 0) return "Invalid size";
const units = ["B", "KB", "MB", "GB", "TB"];
const k = 1024;
const i = Math.floor(Math.log(bytes) / Math.log(k));
const size = bytes / Math.pow(k, i);
const formatted = i === 0 ? size.toString() : size.toFixed(1);
return `${formatted} ${units[i]}`;
}
export function getFileTypeLabel(mimeType: string): string {
if (!mimeType) return "File";
const [category, subtype] = mimeType.split("/");
const specialCases: Record<string, string> = {
"application/pdf": "PDF",
"application/zip": "ZIP",
"application/x-zip-compressed": "ZIP",
"application/json": "JSON",
"application/xml": "XML",
"text/plain": "TXT",
"text/html": "HTML",
"text/css": "CSS",
"text/javascript": "JS",
"application/javascript": "JS",
};
if (specialCases[mimeType]) {
return specialCases[mimeType];
}
if (category === "image") {
const imageTypes: Record<string, string> = {
jpeg: "JPEG",
jpg: "JPEG",
png: "PNG",
gif: "GIF",
webp: "WebP",
svg: "SVG",
"svg+xml": "SVG",
bmp: "BMP",
ico: "ICO",
};
return imageTypes[subtype] || subtype.toUpperCase();
}
if (category === "video") {
const videoTypes: Record<string, string> = {
mp4: "MP4",
webm: "WebM",
ogg: "OGG",
avi: "AVI",
mov: "MOV",
quicktime: "MOV",
};
return videoTypes[subtype] || subtype.toUpperCase();
}
if (category === "audio") {
const audioTypes: Record<string, string> = {
mp3: "MP3",
mpeg: "MP3",
wav: "WAV",
ogg: "OGG",
webm: "WebM",
};
return audioTypes[subtype] || subtype.toUpperCase();
}
return subtype ? subtype.toUpperCase() : category.toUpperCase();
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment