Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 19 additions & 3 deletions web/src/components/MemoActionMenu/MemoActionMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
BookmarkMinusIcon,
BookmarkPlusIcon,
CopyIcon,
DownloadIcon,
Edit3Icon,
FileTextIcon,
LinkIcon,
Expand Down Expand Up @@ -49,6 +50,7 @@ const MemoActionMenu = (props: MemoActionMenuProps) => {
handleToggleMemoStatusClick,
handleCopyLink,
handleCopyContent,
handleDownloadContent,
handleDeleteMemoClick,
confirmDeleteMemo,
handleRemoveCompletedTaskListItemsClick,
Expand All @@ -73,7 +75,11 @@ const MemoActionMenu = (props: MemoActionMenuProps) => {
<>
{!isComment && (
<DropdownMenuItem onClick={handleTogglePinMemoBtnClick}>
{memo.pinned ? <BookmarkMinusIcon className="w-4 h-auto" /> : <BookmarkPlusIcon className="w-4 h-auto" />}
{memo.pinned ? (
<BookmarkMinusIcon className="w-4 h-auto" />
) : (
<BookmarkPlusIcon className="w-4 h-auto" />
)}
{memo.pinned ? t("common.unpin") : t("common.pin")}
</DropdownMenuItem>
)}
Expand All @@ -91,6 +97,10 @@ const MemoActionMenu = (props: MemoActionMenuProps) => {
<CopyIcon className="w-4 h-auto" />
{t("common.copy")}
</DropdownMenuSubTrigger>
<DropdownMenuItem onClick={handleDownloadContent}>
<DownloadIcon className="w-4 h-auto" />
{t("memo.download")}
</DropdownMenuItem>
<DropdownMenuSubContent>
<DropdownMenuItem onClick={handleCopyLink}>
<LinkIcon className="w-4 h-auto" />
Expand All @@ -109,7 +119,9 @@ const MemoActionMenu = (props: MemoActionMenuProps) => {
<>
{/* Remove completed tasks (non-archived, non-comment, has completed tasks) */}
{!isArchived && !isComment && hasCompletedTaskList && (
<DropdownMenuItem onClick={handleRemoveCompletedTaskListItemsClick}>
<DropdownMenuItem
onClick={handleRemoveCompletedTaskListItemsClick}
>
<SquareCheckIcon className="w-4 h-auto" />
{t("memo.remove-completed-task-list-items")}
</DropdownMenuItem>
Expand All @@ -118,7 +130,11 @@ const MemoActionMenu = (props: MemoActionMenuProps) => {
{/* Archive/Restore (non-comment) */}
{!isComment && (
<DropdownMenuItem onClick={handleToggleMemoStatusClick}>
{isArchived ? <ArchiveRestoreIcon className="w-4 h-auto" /> : <ArchiveIcon className="w-4 h-auto" />}
{isArchived ? (
<ArchiveRestoreIcon className="w-4 h-auto" />
) : (
<ArchiveIcon className="w-4 h-auto" />
)}
{isArchived ? t("common.restore") : t("common.archive")}
</DropdownMenuItem>
)}
Expand Down
20 changes: 18 additions & 2 deletions web/src/components/MemoActionMenu/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@ import { userKeys } from "@/hooks/useUserQueries";
import { handleError } from "@/lib/error";
import { State } from "@/types/proto/api/v1/common_pb";
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
import { toAttachmentItems } from "@/components/memo-metadata";
import { useTranslate } from "@/utils/i18n";
import { removeCompletedTasks } from "@/utils/markdown-manipulation";
import { downloadMemoContentAndAttachments } from "@/utils/content";

interface UseMemoActionHandlersOptions {
memo: Memo;
Expand All @@ -20,7 +22,12 @@ interface UseMemoActionHandlersOptions {
setRemoveTasksDialogOpen: (open: boolean) => void;
}

export const useMemoActionHandlers = ({ memo, onEdit, setDeleteDialogOpen, setRemoveTasksDialogOpen }: UseMemoActionHandlersOptions) => {
export const useMemoActionHandlers = ({
memo,
onEdit,
setDeleteDialogOpen,
setRemoveTasksDialogOpen,
}: UseMemoActionHandlersOptions) => {
const t = useTranslate();
const location = useLocation();
const navigateTo = useNavigateTo();
Expand Down Expand Up @@ -56,7 +63,10 @@ export const useMemoActionHandlers = ({ memo, onEdit, setDeleteDialogOpen, setRe
const handleToggleMemoStatusClick = useCallback(async () => {
const isArchiving = memo.state !== State.ARCHIVED;
const state = memo.state === State.ARCHIVED ? State.NORMAL : State.ARCHIVED;
const message = memo.state === State.ARCHIVED ? t("message.restored-successfully") : t("message.archived-successfully");
const message =
memo.state === State.ARCHIVED
? t("message.restored-successfully")
: t("message.archived-successfully");

try {
await updateMemo({
Expand Down Expand Up @@ -99,6 +109,11 @@ export const useMemoActionHandlers = ({ memo, onEdit, setDeleteDialogOpen, setRe
setDeleteDialogOpen(true);
}, [setDeleteDialogOpen]);

const handleDownloadContent = useCallback(() => {
const attachmentItems = toAttachmentItems(memo.attachments, []);
downloadMemoContentAndAttachments(memo, attachmentItems);
}, [memo, memo.attachments]);

const confirmDeleteMemo = useCallback(async () => {
await deleteMemo(memo.name);
toast.success(t("message.deleted-successfully"));
Expand Down Expand Up @@ -132,6 +147,7 @@ export const useMemoActionHandlers = ({ memo, onEdit, setDeleteDialogOpen, setRe
handleCopyLink,
handleCopyContent,
handleDeleteMemoClick,
handleDownloadContent,
confirmDeleteMemo,
handleRemoveCompletedTaskListItemsClick,
confirmRemoveCompletedTaskListItems,
Expand Down
1 change: 1 addition & 0 deletions web/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@
},
"copy-content": "Copy Content",
"copy-link": "Copy Link",
"download": "Download",
"count-memos-in-date": "{{count}} {{memos}} in {{date}}",
"delete-confirm": "Are you sure you want to delete this memo?",
"delete-confirm-description": "This action is irreversible. Attachments, links, and references will also be removed.",
Expand Down
111 changes: 111 additions & 0 deletions web/src/utils/content.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
import type { AttachmentItem } from "@/components/memo-metadata";

/**
* Downloads a file using a temporary anchor element
* @param url - The URL to download from
* @param filename - The filename to save as
* @param isLocal - Whether this is a local file that needs special handling
*/
const downloadFile = (
url: string,
filename: string,
isLocal: boolean = false,
): void => {
const downloadElement = document.createElement("a");
downloadElement.href = url;
downloadElement.download = filename;

// For local files, ensure proper download behavior
if (isLocal) {
downloadElement.setAttribute("download", filename);
downloadElement.target = "_blank";
}

document.body.appendChild(downloadElement);
downloadElement.click();
document.body.removeChild(downloadElement);
};

/**
* Creates a date prefix in YYYY/MM/DD format
* @param date - Date object to format (defaults to current date)
* @returns Formatted date string
*/
const createDatePrefix = (date: Date = new Date()): string => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}/${month}/${day}`;
};

/**
* Extracts memo short name from full name path
* @param memoName - Full memo name (format: memos/{id})
* @returns Short memo name
*/
const extractMemoName = (memoName: string): string => {
return memoName.split("/").pop() || "memo";
};

/**
* Downloads memo content as a markdown file if content exists
* @param memo - The memo object
* @param namePrefix - The filename prefix to use
*/
const downloadMemoContent = (memo: Memo, namePrefix: string): void => {
if (!memo.content || !memo.content.trim()) {
return;
}

const contentBlob = new Blob([memo.content], {
type: "text/markdown;charset=utf-8",
});
const contentUrl = URL.createObjectURL(contentBlob);

downloadFile(contentUrl, `${namePrefix}.md`);

URL.revokeObjectURL(contentUrl);
};

/**
* Downloads all attachments from a memo
* @param attachmentItems - Array of attachment items
* @param namePrefix - The filename prefix to use
*/
const downloadAttachments = (
attachmentItems: AttachmentItem[],
namePrefix: string,
): void => {
attachmentItems.forEach((item) => {
downloadFile(
item.sourceUrl,
`${namePrefix} - ${item.filename}`,
item.isLocal,
);
});
};

/**
* Downloads all content and attachments from a memo
* @param memo - The memo object
* @param attachmentItems - Array of attachment items
*/
export const downloadMemoContentAndAttachments = (
memo: Memo,
attachmentItems: AttachmentItem[],
): void => {
const date = new Date();
const datePrefix = createDatePrefix(date);
const memoShortName = extractMemoName(memo.name);
const namePrefix = `${datePrefix} ${memoShortName}`;

// Download memo content first
downloadMemoContent(memo, namePrefix);

// Then download all attachments
downloadAttachments(attachmentItems, namePrefix);
};

// Legacy export for backward compatibility
export const download = downloadMemoContentAndAttachments;